chore: 初始开源发布

iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
MiFaZhan
2026-10-22 16:37:56 +08:00
commit 2bec257289
361 changed files with 71524 additions and 0 deletions
@@ -0,0 +1,493 @@
package monthlyusage
import (
"context"
"errors"
"fmt"
"sort"
"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/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
)
var errProducerDone = gerror.New("run production already closed")
func producerStateError(state zset.ProducerState) error {
if state.Status == "completed" || state.Status == "completed_with_failures" {
return ErrRunFinished
}
if state.ProducerDone {
return errProducerDone
}
if state.Status != "producing" && state.Status != "running" {
return gerror.Newf("unexpected producer run status %q", state.Status)
}
return nil
}
// produceChannels owns all per-attempt coordination. There is one worker per
// target, NOT a pool of four long-lived channel workers. SQL tokens and the
// cancellable admission gate never span sleeps or goroutine joins.
func (p *Producer) produceChannels(ctx context.Context, message model.TrafficUsageMonthlyStartMessage, scope *model.SyncScope, scanUpperID int64, startedAt time.Time, total *productionStats) error {
scanner := p.scanner.(channelScanner)
metadata, err := scanner.ListChannels(ctx)
if err != nil {
return gerror.Wrap(err, "initialize channel scan")
}
state, err := p.store.ReadProducerState(ctx, message.RunID, "")
if err != nil {
return err
}
if err = producerStateError(state); err != nil {
return err
}
targets := make(map[string]channelMetadata, len(metadata))
known := make(map[string]bool, len(metadata))
for _, channel := range metadata {
if channel.ChanKey == "" || !scope.Allows(channel.ChanKey) {
continue
}
if known[channel.ChanKey] {
return gerror.Newf("duplicate channel metadata %q", channel.ChanKey)
}
known[channel.ChanKey] = true
reason := ""
if channel.SyncEnabled {
reason = "sync_disabled"
} else if channel.TempKey == "" {
reason = "missing_channel_template"
}
if reason != "" {
total.skippedChannels++
g.Log().Warningf(ctx, "event=monthly_usage_channel status=skipped runId=%s chanKey=%s reason=%s skipUnit=channel", message.RunID, channel.ChanKey, reason)
continue
}
targets[channel.ChanKey] = channel
}
if scope != nil {
for _, chanKey := range scope.Channels {
if !known[chanKey] {
total.skippedChannels++
g.Log().Warningf(ctx, "event=monthly_usage_channel status=skipped runId=%s chanKey=%s reason=unknown_channel skipUnit=channel", message.RunID, chanKey)
}
}
}
g.Log().Infof(ctx, "event=monthly_usage_channel_scan status=started runId=%s targetChannels=%d scanUpperID=%d scanConcurrency=%d pageSize=%d skipUnit=channel", message.RunID, len(targets), scanUpperID, p.config.ScanConcurrency, p.config.PageSize)
keys := make([]string, 0, len(targets))
for key := range targets {
keys = append(keys, key)
}
sort.Strings(keys)
finishObservation := p.startObservation(ctx, message.RunID, keys, startedAt)
observationSuccess := false
defer func() { finishObservation(observationSuccess) }()
var (
workers sync.WaitGroup
slots = make(chan struct{}, p.config.ScanConcurrency)
gate = make(chan struct{}, 1)
metrics = newScanActivity()
results = make([]*channelScanStats, len(keys))
)
pool, hasPool := scanner.(interface {
poolWait(context.Context) (int64, int64)
})
var poolCount, poolNanos int64
if hasPool {
poolCount, poolNanos = pool.poolWait(ctx)
}
workCtx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
for i, key := range keys {
stats := &channelScanStats{chanKey: key, lastSummary: time.Now()}
results[i] = stats
workers.Add(1)
go func(channel channelMetadata, stats *channelScanStats) {
defer workers.Done()
scanErr := p.scanOneChannel(workCtx, scanner, message, channel, scanUpperID, startedAt, slots, gate, metrics, stats)
if scanErr != nil {
cancel(scanErr)
}
reason := stats.stopReason
if reason == "" {
reason = "scan_complete"
}
if scanErr != nil {
reason = "cancelled"
}
p.observe(channel.ChanKey, reason, 0, false, true)
stats.log(workCtx, message.RunID, "ended", startedAt, scanErr)
}(targets[key], stats)
}
workers.Wait()
var summary channelScanStats
for _, stats := range results {
total.pages += stats.scanPages
total.scanned += stats.eligible
total.eligible += stats.eligible
total.enqueued += stats.added
total.stoppedTasks += stats.rejected
if stats.eligible > 0 {
total.channels[stats.chanKey] = struct{}{}
}
summary.merge(stats)
}
metrics.log(ctx, message.RunID, &summary)
if hasPool {
count, nanos := pool.poolWait(ctx)
g.Log().Infof(ctx, "event=monthly_usage_scan_pool runId=%s sharedPoolWaitCountDelta=%d sharedPoolWaitMsDelta=%d attribution=shared_pool", message.RunID, count-poolCount, (nanos-poolNanos)/int64(time.Millisecond))
}
observationSuccess = context.Cause(workCtx) == nil
return context.Cause(workCtx)
}
func acquireContext(ctx context.Context, token chan struct{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case token <- struct{}{}:
if err := ctx.Err(); err != nil {
<-token
return err
}
return nil
}
}
func (p *Producer) scanOneChannel(ctx context.Context, scanner channelScanner, message model.TrafficUsageMonthlyStartMessage, channel channelMetadata, scanUpperID int64, startedAt time.Time, slots, gate chan struct{}, metrics *scanActivity, stats *channelScanStats) error {
for stats.cursor < scanUpperID {
// Detect a blocked/closed run before querying, including empty channels.
p.observe(channel.ChanKey, "redis_read", 0, false, false)
state, err := p.store.ReadProducerState(ctx, message.RunID, channel.ChanKey)
if err != nil {
return err
}
if err = producerStateError(state); err != nil {
return err
}
if state.Blocked {
// No page was read: the unknown unscanned remainder has no card
// count. Never turn it into a fabricated success/failure total.
stats.stopReason = "blocked_unscanned_remainder"
return nil
}
p.observe(channel.ChanKey, "sql_slot", 0, false, false)
waitStart := time.Now()
if err = acquireContext(ctx, slots); err != nil {
return err
}
stats.slotWait += time.Since(waitStart)
metrics.begin()
p.observe(channel.ChanKey, "sql_query", 0, false, false)
queryStart := time.Now()
cards, scanErr := scanner.ScanChannel(ctx, channel.ChanKey, stats.cursor, p.config.PageSize)
elapsed := time.Since(queryStart)
metrics.end()
<-slots // release immediately after results have been materialized
stats.sqlTime += elapsed
stats.sqlLatency.observe(elapsed)
stats.scanPages++
if scanErr != nil {
return gerror.Wrapf(scanErr, "scan channel %s after %d", channel.ChanKey, stats.cursor)
}
if len(cards) > p.config.PageSize {
return gerror.New("scanner exceeded page size")
}
if len(cards) == 0 {
stats.stopReason = "empty_result"
return nil
}
var (
pageCursor = stats.cursor
overUpper bool
tasks = make([]zset.Task, 0, len(cards))
)
for i, card := range cards {
if card.ChanKey != channel.ChanKey || card.ID <= pageCursor {
return gerror.New("channel scanner returned unordered or foreign cards")
}
if card.ID > scanUpperID {
stats.overUpper += int64(len(cards) - i)
overUpper = true
break
}
pageCursor = card.ID
tasks = append(tasks, zset.Task{TaskID: zset.StableTaskID(message.RunID, message.Operation, card.ID), RunID: message.RunID, Operation: message.Operation, QueryMonth: message.QueryMonth, ChanKey: channel.ChanKey, TempKey: channel.TempKey, Operator: channel.Operator, CardID: card.ID, ICCID: card.ICCID, MSISDN: card.MSISDN, IMSI: card.IMSI})
}
p.observe(channel.ChanKey, "page_ready", len(tasks), false, false)
stats.eligible += int64(len(tasks))
blocked, err := p.admitChannelPage(ctx, message.RunID, tasks, startedAt, gate, stats)
if err != nil {
return err // page cursor is not committed, even after a partial write
}
if blocked {
stats.stopReason = "blocked"
return nil
}
stats.cursor = pageCursor
stats.progress(ctx, message.RunID, startedAt)
if overUpper || stats.cursor >= scanUpperID {
stats.stopReason = "upper_bound"
return nil
}
// Do not use a short page as EOF: query-time eligibility may change.
}
return ctx.Err()
}
// admitChannelPage admits only new TaskIDs, in capacity-sized slices. Each
// worker retains at most this page. No capacity reservation survives the gate.
func (p *Producer) admitChannelPage(ctx context.Context, runID string, tasks []zset.Task, startedAt time.Time, gate chan struct{}, stats *channelScanStats) (bool, error) {
for len(tasks) > 0 {
p.observe(stats.chanKey, "admission_gate", len(tasks), false, false)
gateStart := time.Now()
if err := acquireContext(ctx, gate); err != nil {
return false, err
}
stats.gateWait += time.Since(gateStart)
blocked, waitKind, remaining, err := p.admitChannelSlice(ctx, runID, tasks, startedAt, stats)
<-gate
if err != nil || blocked {
return blocked, err
}
tasks = remaining
p.observe(stats.chanKey, "page_ready", len(tasks), false, false)
if waitKind != "" {
p.observe(stats.chanKey, waitKind+"_capacity", len(tasks), false, false)
waitStart := time.Now()
err = sleepContext(ctx, p.config.PollInterval)
if waitKind == "global" {
stats.globalWait += time.Since(waitStart)
} else {
stats.channelWait += time.Since(waitStart)
}
stats.progress(ctx, runID, startedAt)
if err != nil {
return false, err
}
}
}
return false, ctx.Err()
}
// Caller holds the short admission gate; there is no sleep in this method.
func (p *Producer) admitChannelSlice(ctx context.Context, runID string, tasks []zset.Task, startedAt time.Time, stats *channelScanStats) (blocked bool, waitKind string, remaining []zset.Task, err error) {
p.observe(stats.chanKey, "redis_read", len(tasks), false, false)
readStart := time.Now()
state, err := p.store.ReadProducerState(ctx, runID, stats.chanKey)
if err != nil {
return false, "", tasks, err
}
if err = producerStateError(state); err != nil {
return false, "", tasks, err
}
existing, err := p.store.ExistingRunTasks(ctx, runID, tasks)
stats.stateTime += time.Since(readStart)
if err != nil {
return false, "", tasks, err
}
pending := tasks[:0]
for i, exists := range existing {
if exists {
stats.duplicates++
} else {
pending = append(pending, tasks[i])
}
}
tasks = pending
p.observe(stats.chanKey, "redis_read", len(tasks), false, false)
if state.Blocked {
stats.rejected += int64(len(tasks))
return true, "", nil, nil
}
if len(tasks) == 0 {
return false, "", nil, nil
}
space := int64(len(tasks))
if limit := p.config.GlobalHighWaterMark; limit > 0 {
space = min(space, limit-state.ActiveCount) // inflight is already included
if space <= 0 {
return false, "global", tasks, nil
}
}
if limit := p.config.ChannelHighWaterMark; limit > 0 {
key, keyErr := p.store.RoundActiveKey(runID, state.CurrentRound, stats.chanKey)
if keyErr != nil {
return false, "", tasks, keyErr
}
readStart = time.Now()
size, sizeErr := p.store.SizeKey(ctx, key)
stats.stateTime += time.Since(readStart)
if sizeErr != nil {
return false, "", tasks, sizeErr
}
space = min(space, limit-size)
if space <= 0 {
return false, "channel", tasks, nil
}
}
batch := tasks[:int(space)]
p.observe(stats.chanKey, "redis_write", len(tasks), false, false)
enqueueStart := time.Now()
added, err := p.store.EnqueueRunBatch(ctx, runID, stats.chanKey, batch, time.Now())
stats.enqueueTime += time.Since(enqueueStart)
stats.enqueueBatches++
if err != nil {
return false, "", tasks, err // uncertain responses require idempotent rescan
}
if added < 0 || added > len(batch) {
return false, "", tasks, gerror.New("invalid enqueue count")
}
stats.added += int64(added)
p.observe(stats.chanKey, "redis_write", len(tasks)-len(batch), added > 0, false)
if added > 0 && !stats.firstEnqueueRecorded {
stats.firstEnqueueRecorded = true
stats.firstEnqueue = time.Since(startedAt)
stats.log(ctx, runID, "first_enqueue", startedAt, nil)
}
if added < len(batch) {
readStart = time.Now()
confirmed, checkErr := p.store.ExistingRunTasks(ctx, runID, batch)
if checkErr != nil {
return false, "", tasks, checkErr
}
state, checkErr = p.store.ReadProducerState(ctx, runID, stats.chanKey)
stats.stateTime += time.Since(readStart)
if checkErr != nil {
return false, "", tasks, checkErr
}
if checkErr = producerStateError(state); checkErr != nil {
return false, "", tasks, checkErr
}
present := 0
for _, exists := range confirmed {
if exists {
present++
}
}
if present < added {
return false, "", tasks, gerror.New("enqueue count exceeds confirmed task states")
}
stats.duplicates += int64(present - added)
if state.Blocked {
stats.rejected += int64(len(tasks) - present)
return true, "", nil, nil
}
if present != len(batch) {
return false, "", tasks, gerror.Newf("unexplained enqueue rejection: added=%d submitted=%d confirmed=%d", added, len(batch), present)
}
}
return false, "", tasks[len(batch):], nil
}
// Retained only to keep errors.Is semantics visible at the call site.
func isProductionClosed(err error) bool { return errors.Is(err, errProducerDone) }
func durationMS(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) }
// latencyHistogram is fixed-size. Reported percentiles are bucket upper bounds,
// never exact samples or a server-side SLA; the last bucket is unbounded.
var latencyBounds = [...]time.Duration{time.Millisecond, 2 * time.Millisecond, 5 * time.Millisecond, 10 * time.Millisecond, 20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second, time.Minute}
type latencyHistogram struct{ counts [16]int64 }
func (h *latencyHistogram) observe(d time.Duration) {
i := 0
for i < len(latencyBounds) && d > latencyBounds[i] {
i++
}
h.counts[i]++
}
func (h *latencyHistogram) percentile(percent int64) string {
var total, cumulative int64
for _, count := range h.counts {
total += count
}
if total == 0 {
return "n/a"
}
target := (total*percent + 99) / 100
for i, count := range h.counts {
cumulative += count
if cumulative >= target {
if i == len(latencyBounds) {
return ">60000"
}
return fmt.Sprintf("%.0f", durationMS(latencyBounds[i]))
}
}
return "n/a"
}
type channelScanStats struct {
firstEnqueueRecorded bool
chanKey, stopReason string
cursor, scanPages, eligible, overUpper, enqueueBatches, added, duplicates, rejected int64
sqlTime, slotWait, gateWait, globalWait, channelWait, stateTime, enqueueTime, firstEnqueue time.Duration
sqlLatency latencyHistogram
lastSummary time.Time
}
func (s *channelScanStats) log(ctx context.Context, runID, status string, startedAt time.Time, err error) {
errorText := ""
if err != nil {
errorText = err.Error()
}
g.Log().Infof(ctx, "event=monthly_usage_channel_scan status=%s runId=%s chanKey=%s cursor=%d scanPages=%d eligible=%d overUpper=%d enqueueBatches=%d added=%d duplicates=%d rejected=%d sqlMs=%.3f sqlP50UpperMs=%s sqlP95UpperMs=%s slotWaitMs=%.3f admissionWaitMs=%.3f globalCapacityWaitMs=%.3f channelCapacityWaitMs=%.3f stateReadMs=%.3f enqueueMs=%.3f firstEnqueueMs=%.3f wallMs=%d stopReason=%s error=%q", status, runID, s.chanKey, s.cursor, s.scanPages, s.eligible, s.overUpper, s.enqueueBatches, s.added, s.duplicates, s.rejected, durationMS(s.sqlTime), s.sqlLatency.percentile(50), s.sqlLatency.percentile(95), durationMS(s.slotWait), durationMS(s.gateWait), durationMS(s.globalWait), durationMS(s.channelWait), durationMS(s.stateTime), durationMS(s.enqueueTime), durationMS(s.firstEnqueue), time.Since(startedAt).Milliseconds(), s.stopReason, errorText)
}
func (s *channelScanStats) progress(ctx context.Context, runID string, startedAt time.Time) {
if time.Since(s.lastSummary) >= time.Minute {
s.lastSummary = time.Now()
s.log(ctx, runID, "progress", startedAt, nil)
}
}
func (s *channelScanStats) merge(other *channelScanStats) {
s.scanPages += other.scanPages
s.eligible += other.eligible
s.overUpper += other.overUpper
s.enqueueBatches += other.enqueueBatches
s.added += other.added
s.duplicates += other.duplicates
s.rejected += other.rejected
s.sqlTime += other.sqlTime
s.slotWait += other.slotWait
s.gateWait += other.gateWait
s.globalWait += other.globalWait
s.channelWait += other.channelWait
s.stateTime += other.stateTime
s.enqueueTime += other.enqueueTime
for i, count := range other.sqlLatency.counts {
s.sqlLatency.counts[i] += count
}
}
type scanActivity struct {
mu sync.Mutex
started, changed time.Time
running, peak int
area time.Duration
}
func newScanActivity() *scanActivity {
now := time.Now()
return &scanActivity{started: now, changed: now}
}
func (s *scanActivity) change(delta int) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
s.area += time.Duration(s.running) * now.Sub(s.changed)
s.changed = now
s.running += delta
s.peak = max(s.peak, s.running)
}
func (s *scanActivity) begin() { s.change(1) }
func (s *scanActivity) end() { s.change(-1) }
func (s *scanActivity) log(ctx context.Context, runID string, stats *channelScanStats) {
wall := time.Since(s.started)
g.Log().Infof(ctx, "event=monthly_usage_scan_summary runId=%s wallMs=%d sqlInFlightPeak=%d sqlInFlightAvg=%.4f scanPages=%d enqueueBatches=%d eligible=%d overUpper=%d added=%d duplicates=%d rejected=%d sqlCumulativeMs=%.3f sqlP50UpperMs=%s sqlP95UpperMs=%s slotWaitCumulativeMs=%.3f admissionWaitCumulativeMs=%.3f globalCapacityWaitCumulativeMs=%.3f channelCapacityWaitCumulativeMs=%.3f stateReadCumulativeMs=%.3f enqueueCumulativeMs=%.3f", runID, wall.Milliseconds(), s.peak, float64(s.area)/float64(wall), stats.scanPages, stats.enqueueBatches, stats.eligible, stats.overUpper, stats.added, stats.duplicates, stats.rejected, durationMS(stats.sqlTime), stats.sqlLatency.percentile(50), stats.sqlLatency.percentile(95), durationMS(stats.slotWait), durationMS(stats.gateWait), durationMS(stats.globalWait), durationMS(stats.channelWait), durationMS(stats.stateTime), durationMS(stats.enqueueTime))
}
@@ -0,0 +1,522 @@
package monthlyusage
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
"github.com/gogf/gf/v2/container/gvar"
)
// datasetScanner applies the SQL eligibility predicate before LIMIT; metadata
// eligibility is applied by the producer. Optional hooks simulate SQL latency,
// live additions and failures without accessing an application database.
type datasetScanner struct {
cards []Card
channels []channelMetadata
upper int64
hook func(context.Context, string, int64) error
queries atomic.Int32
lists atomic.Int32
inflight atomic.Int32
peak atomic.Int32
}
func (s *datasetScanner) SnapshotMaxID(context.Context) (int64, error) { return s.upper, nil }
func (s *datasetScanner) ListChannels(context.Context) ([]channelMetadata, error) {
s.lists.Add(1)
return s.channels, nil
}
func (s *datasetScanner) Scan(ctx context.Context, last, upper int64, size int) ([]Card, error) {
var rows []Card
for _, card := range s.cards {
if card.ID > last && card.ID <= upper {
for _, channel := range s.channels {
if channel.ChanKey == card.ChanKey {
card.TempKey = channel.TempKey
card.SyncEnabled = channel.SyncEnabled
card.Operator = channel.Operator
}
}
rows = append(rows, card)
if len(rows) == size {
break
}
}
}
return rows, nil
}
func (s *datasetScanner) ScanChannel(ctx context.Context, key string, last int64, size int) ([]Card, error) {
s.queries.Add(1)
running := s.inflight.Add(1)
defer s.inflight.Add(-1)
for {
old := s.peak.Load()
if running <= old || s.peak.CompareAndSwap(old, running) {
break
}
}
if s.hook != nil {
if err := s.hook(ctx, key, last); err != nil {
return nil, err
}
}
var rows []Card
for _, card := range s.cards {
if card.ChanKey == key && card.ID > last {
rows = append(rows, card)
if len(rows) == size {
break
}
}
}
return rows, nil
}
func scannerFor(cards []Card, upper int64) *datasetScanner {
keys := map[string]bool{}
s := &datasetScanner{cards: cards, upper: upper}
for _, card := range cards {
if card.ChanKey != "" && !keys[card.ChanKey] {
keys[card.ChanKey] = true
s.channels = append(s.channels, channelMetadata{ChanKey: card.ChanKey, TempKey: "sim_yd_v5", Operator: 1})
}
}
return s
}
func testStore(t *testing.T) *zset.Store {
t.Helper()
store, err := zset.New(testutil.Redis(t), "flow:")
if err != nil {
t.Fatal(err)
}
return store
}
func testProducer(t *testing.T, scanner CardScanner, store *zset.Store, config Config) *Producer {
t.Helper()
config.RoundRetryEnabled = true
p, err := New(scanner, store, config)
if err != nil {
t.Fatal(err)
}
return p
}
func contextForTest(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
return ctx
}
func taskIDs(t *testing.T, store *zset.Store, run string) []string {
t.Helper()
key, _ := store.TaskStateKey(run)
result, err := store.RedisClient().Do(context.Background(), "HKEYS", key)
if err != nil {
t.Fatal(err)
}
ids := result.Strings()
sort.Strings(ids)
return ids
}
func requireMeta(t *testing.T, store *zset.Store, run string, done bool, active int64) {
t.Helper()
meta, err := store.GetRunMeta(context.Background(), run)
if err != nil {
t.Fatal(err)
}
if meta.ProducerDone != done || meta.ActiveCount != active {
t.Fatalf("meta=%+v, want done=%t active=%d", meta, done, active)
}
}
func waitUntil(t *testing.T, ctx context.Context, condition func() bool) {
t.Helper()
for !condition() {
select {
case <-ctx.Done():
t.Fatal(ctx.Err())
case <-time.After(time.Millisecond):
}
}
}
func TestChannelModeSettings(t *testing.T) {
store, _ := zset.New(&producerRedis{}, "flow:")
p, err := New(&fakeScanner{}, store, Config{})
if err != nil || p.config.ScanConcurrency != 4 {
t.Fatalf("defaults: p=%+v err=%v", p, err)
}
for _, config := range []Config{{ScanConcurrency: -1}} {
if _, err = New(&fakeScanner{}, store, config); err == nil {
t.Fatalf("accepted invalid config/capability %+v", config)
}
}
}
func TestChannelScanFiniteMemoryBoundary(t *testing.T) {
for _, test := range []struct {
name string
upper int64
cards []Card
ids []int64
queries int32
}{
{"zero upper", 0, []Card{{ID: 1, ChanKey: "a"}}, nil, 0},
{"exact page", 4, []Card{{ID: 1, ChanKey: "a"}, {ID: 2, ChanKey: "a"}, {ID: 3, ChanKey: "a"}, {ID: 4, ChanKey: "a"}}, []int64{1, 2, 3, 4}, 2},
{"tail then empty", 9, []Card{{ID: 1, ChanKey: "a"}, {ID: 2, ChanKey: "a"}, {ID: 3, ChanKey: "a"}}, []int64{1, 2, 3}, 3},
{"first outside", 3, []Card{{ID: 4, ChanKey: "a"}, {ID: 5, ChanKey: "a"}}, nil, 1},
{"mixed final page", 3, []Card{{ID: 1, ChanKey: "a"}, {ID: 2, ChanKey: "a"}, {ID: 3, ChanKey: "a"}, {ID: 4, ChanKey: "a"}, {ID: 5, ChanKey: "a"}}, []int64{1, 2, 3}, 2},
{"empty channel", 9, nil, nil, 1},
} {
t.Run(test.name, func(t *testing.T) {
store := testStore(t)
scanner := scannerFor(test.cards, test.upper)
if len(scanner.channels) == 0 {
scanner.channels = []channelMetadata{{ChanKey: "a", TempKey: "sim_yd_v5"}}
}
p := testProducer(t, scanner, store, Config{PageSize: 2})
if err := p.Produce(contextForTest(t), scopedMessage("boundary")); err != nil {
t.Fatal(err)
}
var want []string
for _, id := range test.ids {
want = append(want, zset.StableTaskID("boundary", operation, id))
}
sort.Strings(want)
got := taskIDs(t, store, "boundary")
if len(got) != len(want) || (len(want) > 0 && !reflect.DeepEqual(got, want)) {
t.Fatalf("got=%v want=%v", got, want)
}
if scanner.queries.Load() != test.queries {
t.Fatalf("queries=%d want=%d", scanner.queries.Load(), test.queries)
}
requireMeta(t, store, "boundary", true, int64(len(want)))
})
}
}
func TestChannelScanLiveHigherIDsDoNotExtendRun(t *testing.T) {
store := testStore(t)
scanner := scannerFor([]Card{{ID: 1, ChanKey: "a"}, {ID: 2, ChanKey: "a"}, {ID: 3, ChanKey: "a"}}, 3)
scanner.hook = func(_ context.Context, _ string, last int64) error {
if last > 0 {
scanner.cards = append(scanner.cards, Card{ID: 100 + last, ChanKey: "a"})
}
return nil
}
p := testProducer(t, scanner, store, Config{PageSize: 2})
if err := p.Produce(contextForTest(t), scopedMessage("live")); err != nil {
t.Fatal(err)
}
if len(taskIDs(t, store, "live")) != 3 || scanner.queries.Load() != 2 {
t.Fatal("higher IDs extended finite range")
}
}
func TestChannelScanSerialTaskIDSetParity(t *testing.T) {
for _, scope := range [][]string{nil, {"a", "b"}, {"disabled", "unknown"}} {
t.Run(fmt.Sprint(scope), func(t *testing.T) {
store := testStore(t)
cards := []Card{{ID: 1, ChanKey: "a"}, {ID: 2, ChanKey: "b"}, {ID: 3, ChanKey: "a"}, {ID: 4, ChanKey: "disabled"}, {ID: 5, ChanKey: "no-template"}, {ID: 6, ChanKey: "unknown"}, {ID: 7, ChanKey: "b"}, {ID: 8, ChanKey: "a"}}
var sets [][]string
for _, mode := range []string{"serial", "channel"} {
scanner := scannerFor(cards, 7)
scanner.channels = []channelMetadata{{ChanKey: "a", TempKey: "sim_yd_v5", Operator: 1}, {ChanKey: "b", TempKey: "sim_dx_v1", Operator: 2}, {ChanKey: "disabled", TempKey: "sim_yd_v5", SyncEnabled: true}, {ChanKey: "no-template"}}
p := testProducer(t, scanner, store, Config{PageSize: 2})
run := "parity-" + mode
if err := p.Produce(contextForTest(t), scopedMessage(run, scope...)); err != nil {
t.Fatal(err)
}
ids := taskIDs(t, store, run)
for i, id := range ids {
ids[i] = strings.TrimPrefix(id, run+":")
}
sets = append(sets, ids)
if mode == "channel" && scanner.lists.Load() != 1 {
t.Fatal("metadata not loaded once")
}
_, _ = store.RedisClient().Do(context.Background(), "SREM", store.ActiveRunsKey(), run)
}
if !reflect.DeepEqual(sets[0], sets[1]) {
t.Fatalf("serial=%v channel=%v", sets[0], sets[1])
}
})
}
}
func TestChannelSQLSlotsReleasedWhileChannelsWait(t *testing.T) {
store := testStore(t)
ctx := contextForTest(t)
var cards []Card
for i := 0; i < 9; i++ {
cards = append(cards, Card{ID: int64(i + 1), ChanKey: fmt.Sprint(i)}, Card{ID: int64(i + 10), ChanKey: fmt.Sprint(i)})
}
sort.Slice(cards, func(i, j int) bool { return cards[i].ID < cards[j].ID })
scanner := scannerFor(cards, 100)
scanner.hook = func(ctx context.Context, _ string, _ int64) error { return sleepContext(ctx, 5*time.Millisecond) }
p := testProducer(t, scanner, store, Config{PageSize: 2, ScanConcurrency: 4, ChannelHighWaterMark: 1, GlobalHighWaterMark: 100, PollInterval: time.Millisecond})
result := make(chan error, 1)
go func() { result <- p.Produce(ctx, scopedMessage("isolation")) }()
waitUntil(t, ctx, func() bool { return len(taskIDs(t, store, "isolation")) == 9 })
if peak := scanner.peak.Load(); peak > 4 || peak < 2 {
t.Fatalf("SQL peak=%d", peak)
}
// All nine channels got a first task despite the first four waiting at
// channel capacity. SQL slots are free while every channel is paused.
if scanner.inflight.Load() != 0 {
t.Fatal("SQL tokens held during channel backpressure")
}
for _, channel := range scanner.channels {
if _, err := store.BlockRunScoped(ctx, "isolation", channel.ChanKey); err != nil {
t.Fatal(err)
}
}
if err := <-result; err != nil {
t.Fatal(err)
}
meta, err := store.GetRunMeta(ctx, "isolation")
if err != nil || !meta.ProducerDone {
t.Fatalf("meta=%+v err=%v", meta, err)
}
}
func TestChannelRemainingCapacityAndDuplicateAdmission(t *testing.T) {
store := testStore(t)
ctx := contextForTest(t)
scanner := scannerFor(nil, 0)
p := testProducer(t, scanner, store, Config{GlobalHighWaterMark: 3, ChannelHighWaterMark: 2, PollInterval: time.Millisecond})
if err := store.CreateRun(ctx, zset.RunMeta{RunID: "capacity", Status: "producing"}); err != nil {
t.Fatal(err)
}
// Existing success and waiting states still deduplicate and consume no new
// capacity. Active includes leases: inflight must not be added again.
stateKey, _ := store.TaskStateKey("capacity")
metaKey, _ := store.RunMetaKey("capacity")
activeKey, _ := store.RoundActiveKey("capacity", 1, "a")
_, _ = store.RedisClient().Do(ctx, "HSET", stateKey, "one", `{"status":"success"}`, "two", `{"status":"waiting"}`)
_, _ = store.RedisClient().Do(ctx, "HSET", metaKey, "activeCount", 2, "inflightCount", 2)
stats := &channelScanStats{chanKey: "a", lastSummary: time.Now()}
tasks := []zset.Task{{TaskID: "one", RunID: "capacity", ChanKey: "a"}, {TaskID: "two", RunID: "capacity", ChanKey: "a"}, {TaskID: "three", RunID: "capacity", ChanKey: "a"}, {TaskID: "four", RunID: "capacity", ChanKey: "a"}}
gate := make(chan struct{}, 1)
result := make(chan error, 1)
go func() { _, err := p.admitChannelPage(ctx, "capacity", tasks, time.Now(), gate, stats); result <- err }()
waitUntil(t, ctx, func() bool { meta, e := store.GetRunMeta(ctx, "capacity"); return e == nil && meta.ActiveCount == 3 })
if size, err := store.SizeKey(ctx, activeKey); err != nil || size != 1 {
t.Fatalf("initial slice size=%d err=%v", size, err)
}
_, _ = store.RedisClient().Do(ctx, "HSET", metaKey, "activeCount", 0, "inflightCount", 0)
if err := <-result; err != nil {
t.Fatal(err)
}
if stats.duplicates != 2 || stats.added != 2 || stats.enqueueBatches != 2 || stats.globalWait == 0 {
t.Fatalf("stats=%+v", stats)
}
}
// faultRedis forwards to a real isolated Redis, optionally returning a lost
// response after Lua succeeded. Never mocks the Lua semantics under test.
type faultRedis struct {
zset.RedisClient
failEnqueue atomic.Int32
failRead atomic.Bool
failRenew atomic.Bool
lostResponse bool
reject bool
}
func (r *faultRedis) Do(ctx context.Context, command string, args ...any) (*gvar.Var, error) {
if r.failRead.Load() && command == "HMGET" {
return nil, errors.New("Redis read failure")
}
return r.RedisClient.Do(ctx, command, args...)
}
func (r *faultRedis) Eval(ctx context.Context, script string, n int64, keys []string, args []any) (*gvar.Var, error) {
if r.failRenew.Load() && strings.Contains(script, "PEXPIRE") && strings.Contains(script, "owner ~=") {
return gvar.New(0), nil
}
if n == 4 && strings.Contains(script, "HEXISTS") {
if r.reject {
return gvar.New(0), nil
}
if r.failEnqueue.CompareAndSwap(1, 0) {
if r.lostResponse {
if _, err := r.RedisClient.Eval(ctx, script, n, keys, args); err != nil {
return nil, err
}
}
return nil, errors.New("enqueue response unavailable")
}
}
return r.RedisClient.Eval(ctx, script, n, keys, args)
}
func TestChannelRescanPartialAndUncertainWrites(t *testing.T) {
for _, uncertain := range []bool{false, true} {
t.Run(fmt.Sprint(uncertain), func(t *testing.T) {
client := &faultRedis{RedisClient: testutil.Redis(t), lostResponse: uncertain}
store, _ := zset.New(client, "flow:")
scanner := scannerFor([]Card{{ID: 1, ChanKey: "a"}, {ID: 2, ChanKey: "a"}, {ID: 3, ChanKey: "a"}, {ID: 4, ChanKey: "a"}}, 4)
// Fail the second page: first page must survive; uncertain success is
// confirmed only on a same-Run rescan, with stable IDs and no double count.
scanner.hook = func(_ context.Context, _ string, last int64) error {
if last == 2 {
client.failEnqueue.Store(1)
}
return nil
}
p := testProducer(t, scanner, store, Config{PageSize: 2})
if err := p.Produce(contextForTest(t), scopedMessage("rescan")); err == nil {
t.Fatal("expected enqueue failure")
}
active := int64(2)
if uncertain {
active = 4
}
requireMeta(t, store, "rescan", false, active)
scanner.hook = nil
if err := p.Produce(contextForTest(t), scopedMessage("rescan")); err != nil {
t.Fatal(err)
}
requireMeta(t, store, "rescan", true, 4)
if len(taskIDs(t, store, "rescan")) != 4 {
t.Fatal("wrong stable task set")
}
before := scanner.queries.Load()
if err := p.Produce(contextForTest(t), scopedMessage("rescan")); err != nil {
t.Fatal(err)
}
if before != scanner.queries.Load() {
t.Fatal("producerDone redelivery rescanned cards")
}
})
}
}
func TestChannelFailureCancelsAllWorkersWithoutProducerDone(t *testing.T) {
for _, kind := range []string{"sql", "redis", "unexplained_rejection", "lease"} {
t.Run(kind, func(t *testing.T) {
client := &faultRedis{RedisClient: testutil.Redis(t)}
store, _ := zset.New(client, "flow:")
cards := []Card{{ID: 1, ChanKey: "a"}, {ID: 2, ChanKey: "b"}, {ID: 3, ChanKey: "c"}}
scanner := scannerFor(cards, 10)
allStarted := make(chan struct{})
var started atomic.Int32
var once sync.Once
scanner.hook = func(ctx context.Context, key string, _ int64) error {
if started.Add(1) >= 3 {
once.Do(func() { close(allStarted) })
}
select {
case <-allStarted:
case <-ctx.Done():
return ctx.Err()
}
if kind == "lease" {
client.failRenew.Store(true)
<-ctx.Done()
return ctx.Err()
}
if key == "a" {
switch kind {
case "sql":
return errors.New("SQL failed")
case "redis":
client.failRead.Store(true)
case "unexplained_rejection":
client.reject = true
}
return nil
}
<-ctx.Done()
return ctx.Err()
}
p := testProducer(t, scanner, store, Config{PageSize: 2, RunLockTTL: 90 * time.Millisecond})
if err := p.Produce(contextForTest(t), scopedMessage("failure")); err == nil {
t.Fatal("expected fatal failure")
}
if scanner.inflight.Load() != 0 {
t.Fatal("scanner goroutines leaked")
}
meta, err := store.GetRunMeta(context.Background(), "failure")
if err != nil || meta.ProducerDone {
t.Fatalf("partial scan marked done: %+v err=%v", meta, err)
}
})
}
}
func TestChannelScanStateStopsAndNoTarget(t *testing.T) {
for _, kind := range []string{"terminal", "producerDone", "blocked", "missing", "no-target", "no-cards"} {
t.Run(kind, func(t *testing.T) {
store := testStore(t)
scanner := scannerFor([]Card{{ID: 1, ChanKey: "a"}}, 2)
if kind == "no-target" {
scanner.channels[0].SyncEnabled = true
}
if kind == "no-cards" {
scanner.cards = nil
}
p := testProducer(t, scanner, store, Config{})
if kind != "no-target" && kind != "no-cards" {
if err := store.CreateRun(context.Background(), zset.RunMeta{RunID: "stop", Status: "producing"}); err != nil {
t.Fatal(err)
}
key, _ := store.RunMetaKey("stop")
switch kind {
case "terminal":
_, _ = store.RedisClient().Do(context.Background(), "HSET", key, "status", "completed")
case "producerDone":
if err := store.MarkProducerDone(context.Background(), "stop"); err != nil {
t.Fatal(err)
}
case "blocked":
_, _ = store.BlockRunScoped(context.Background(), "stop", "a")
case "missing":
scanner.hook = func(ctx context.Context, _ string, _ int64) error {
_, err := store.RedisClient().Do(ctx, "DEL", key)
return err
}
}
}
err := p.Produce(contextForTest(t), scopedMessage("stop"))
if kind == "terminal" {
if !errors.Is(err, ErrRunFinished) {
t.Fatalf("err=%v", err)
}
} else if kind == "missing" {
if !errors.Is(err, zset.ErrRunNotFound) {
t.Fatalf("err=%v", err)
}
} else if err != nil {
t.Fatal(err)
}
if len(taskIDs(t, store, "stop")) != 0 {
t.Fatal("stopped/empty run enqueued tasks")
}
})
}
}
// Omitted retry/scan-mode settings must still use the channel Run producer.
func TestDefaultProducerUsesChannelRun(t *testing.T) {
store := testStore(t)
scanner := scannerFor([]Card{{ID: 1, ChanKey: "a"}}, 1)
p, err := New(scanner, store, Config{})
if err != nil {
t.Fatal(err)
}
if err = p.Produce(contextForTest(t), scopedMessage("default-channel")); err != nil {
t.Fatal(err)
}
if scanner.queries.Load() == 0 || scanner.lists.Load() != 1 {
t.Fatal("default did not use channel scan")
}
requireMeta(t, store, "default-channel", true, 1)
}
@@ -0,0 +1,78 @@
package monthlyusage
import (
"context"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
// channelScanner augments, rather than replaces, the legacy scanner contract.
// ListChannels is called once before any workers start. ScanChannel must not
// mutate the legacy DatabaseScanner.channelMetadata cache.
type channelScanner interface {
ListChannels(context.Context) ([]channelMetadata, error)
ScanChannel(context.Context, string, int64, int) ([]Card, error)
}
// This exact MySQL query shape is a release gate. Do not silently remove hints,
// add an SQL upper bound, or move eligibility filtering after LIMIT.
const channelPageSQL = `SELECT c.id, c.iccid, c.msisdn, c.imsi, c.chan_key
FROM t_sim_card_info AS c FORCE INDEX (idx_card_chan_key)
STRAIGHT_JOIN sys_dept AS d
ON c.dept_id = d.dept_id
AND c.tenant_id = d.tenant_id
WHERE c.chan_key = ?
AND c.id > ?
AND c.iccid <> ''
AND c.current_package_code <> ''
AND d.deleted_at IS NULL
ORDER BY c.id ASC
LIMIT ?`
func (s *DatabaseScanner) ListChannels(ctx context.Context) ([]channelMetadata, error) {
var metadata []channelMetadata
if err := dao.TSimCardInfo.DB().Model("t_sim_channels").Ctx(ctx).Fields("chan_key,temp_key,sync_enabled,operator").Scan(&metadata); err != nil {
return nil, gerror.Wrap(err, "list channel metadata")
}
return metadata, nil
}
func (s *DatabaseScanner) ScanChannel(ctx context.Context, chanKey string, lastID int64, pageSize int) ([]Card, error) {
if chanKey == "" || lastID < 0 || pageSize <= 0 {
return nil, gerror.New("invalid channel pagination parameters")
}
var rows []entity.TSimCardInfo
// Use the card DAO's database group, not a separately configured connection.
if err := dao.TSimCardInfo.DB().Raw(channelPageSQL, chanKey, lastID, pageSize).Ctx(ctx).Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "scan channel cards")
}
cards := make([]Card, 0, len(rows))
for _, row := range rows {
cards = append(cards, Card{ID: row.Id, ICCID: row.Iccid, MSISDN: row.Msisdn, IMSI: row.Imsi, ChanKey: row.ChanKey})
}
return cards, nil
}
// Pool counters are shared with other worker traffic. Their deltas are direct
// observations, not an attribution of all connection waits to this producer.
func (*DatabaseScanner) poolWait(ctx context.Context) (int64, int64) {
var count, nanos int64
for _, item := range dao.TSimCardInfo.DB().Stats(ctx) {
stats := item.Stats()
count += stats.WaitCount
nanos += int64(stats.WaitDuration)
}
return count, nanos
}
// ValidateScanSettings is strict for values read from configuration. The Go
// constructor first fills omitted zero values; explicit YAML zero is rejected.
func ValidateScanSettings(concurrency int) error {
if concurrency <= 0 {
return gerror.New("trafficUsageFullSync.producer.scanConcurrency must be positive")
}
return nil
}
@@ -0,0 +1,122 @@
package monthlyusage
import (
"context"
"reflect"
"sort"
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
)
// SQLite runs the SAME predicate and ordering with only the two MySQL hints
// removed. This checks full eligibility/TaskID parity, not MySQL access plans.
// Production EXPLAIN must still validate the unmodified channelPageSQL.
type sqliteChannelScanner struct{ DatabaseScanner }
func (s *sqliteChannelScanner) ScanChannel(ctx context.Context, key string, last int64, size int) ([]Card, error) {
query := strings.ReplaceAll(channelPageSQL, "FORCE INDEX (idx_card_chan_key)", "")
query = strings.ReplaceAll(query, "STRAIGHT_JOIN", "INNER JOIN")
var rows []struct {
ID int64 `orm:"id"`
ICCID string `orm:"iccid"`
MSISDN string `orm:"msisdn"`
IMSI string `orm:"imsi"`
ChanKey string `orm:"chan_key"`
}
if err := dao.TSimCardInfo.DB().Raw(query, key, last, size).Ctx(ctx).Scan(&rows); err != nil {
return nil, err
}
cards := make([]Card, 0, len(rows))
for _, row := range rows {
cards = append(cards, Card{ID: row.ID, ICCID: row.ICCID, MSISDN: row.MSISDN, IMSI: row.IMSI, ChanKey: row.ChanKey})
}
return cards, nil
}
func TestDatabaseQualificationAndCompleteTaskIDParity(t *testing.T) {
// No application DB/config: isolated SQLite file and loopback Redis only.
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(" + t.TempDir() + "/cards.db)"}}}); err != nil {
t.Fatal(err)
}
ctx := contextForTest(t)
db := dao.TSimCardInfo.DB()
t.Cleanup(func() { _ = db.Close(context.Background()) })
for _, sql := range []string{
`CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, deleted_at DATETIME)`,
`CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, iccid TEXT, msisdn TEXT, imsi TEXT, chan_key TEXT, current_package_code TEXT, dept_id INTEGER, tenant_id TEXT)`,
`CREATE INDEX idx_card_chan_key ON t_sim_card_info(chan_key)`,
`CREATE TABLE t_sim_channels (chan_key TEXT, temp_key TEXT, sync_enabled INTEGER, operator INTEGER)`,
`INSERT INTO sys_dept VALUES (1,'t1',NULL),(2,'t2',NULL),(3,'t1','2026-01-01')`,
`INSERT INTO t_sim_channels VALUES ('a','sim_yd_v5',0,1),('b','sim_dx_v1',0,2),('disabled','sim_yd_v5',1,1),('no-template','',0,1)`,
`INSERT INTO t_sim_card_info VALUES
(1,'i1','m1','s1','a','pkg',1,'t1'),
(2,'i2','m2','s2','b','pkg',2,'t2'),
(3,'i3','','','a','pkg',1,'t1'),
(4,'','','','a','pkg',1,'t1'),
(5,'i5','','','a','',1,'t1'),
(6,'i6','','','a','pkg',1,'wrong-tenant'),
(7,'i7','','','a','pkg',3,'t1'),
(8,'i8','','','b','pkg',99,'t2'),
(9,'i9','','','disabled','pkg',1,'t1'),
(10,'i10','','','no-template','pkg',1,'t1'),
(11,'i11','','','unknown','pkg',1,'t1'),
(12,'i12','','','','pkg',1,'t1'),
(13,'i13','','','b','pkg',2,'t2')`,
} {
if _, err := db.Exec(ctx, sql); err != nil {
t.Fatal(err)
}
}
store := testStore(t)
for _, scope := range [][]string{nil, {"a"}, {"b", "disabled", "no-template", "unknown"}, {"unknown"}} {
var sets [][]string
for _, mode := range []string{"serial", "channel"} {
scanner := &sqliteChannelScanner{}
p := testProducer(t, scanner, store, Config{PageSize: 2})
run := "db-" + mode
if err := p.Produce(ctx, scopedMessage(run, scope...)); err != nil {
t.Fatal(err)
}
ids := taskIDs(t, store, run)
for i, id := range ids {
ids[i] = strings.TrimPrefix(id, run+":")
}
sort.Strings(ids)
sets = append(sets, ids)
stateKey, _ := store.TaskStateKey(run)
metaKey, _ := store.RunMetaKey(run)
chanKey, _ := store.RunChannelsKey(run)
_, _ = store.RedisClient().Do(ctx, "DEL", stateKey, metaKey, chanKey)
for _, key := range []string{"a", "b"} {
activeKey, _ := store.RoundActiveKey(run, 1, key)
_, _ = store.RedisClient().Do(ctx, "DEL", activeKey)
}
_, _ = store.RedisClient().Do(ctx, "SREM", store.ActiveRunsKey(), run)
}
if !reflect.DeepEqual(sets[0], sets[1]) {
t.Fatalf("scope=%v serial=%v channel=%v", scope, sets[0], sets[1])
}
if scope == nil {
want := []string{operation + ":1", operation + ":13", operation + ":2", operation + ":3"}
sort.Strings(want)
if !reflect.DeepEqual(sets[0], want) {
t.Fatalf("qualification set=%v want=%v", sets[0], want)
}
}
}
}
func TestChannelSQLShapeAndParameters(t *testing.T) {
for _, term := range []string{"FORCE INDEX (idx_card_chan_key)", "STRAIGHT_JOIN sys_dept", "c.chan_key = ?", "c.id > ?", "c.iccid <> ''", "c.current_package_code <> ''", "d.deleted_at IS NULL", "c.tenant_id = d.tenant_id", "ORDER BY c.id ASC", "LIMIT ?"} {
if !strings.Contains(channelPageSQL, term) {
t.Fatalf("missing %q", term)
}
}
if strings.Contains(channelPageSQL, "<=") || strings.Count(channelPageSQL, "?") != 3 {
t.Fatal("SQL contains upper bound or changed bind parameter count")
}
}
+485
View File
@@ -0,0 +1,485 @@
package monthlyusage
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
)
const operation = "current_month_usage_full_sync"
// scopeSampleLimit bounds how many channel names are copied into run metadata.
// The run hash and its archive JSON must stay bounded regardless of how many
// channels a scope selects.
const scopeSampleLimit = 10
// scopeDigest is a stable short fingerprint of the selected channels. It lets
// operators confirm two runs covered the same scope without storing the list.
func scopeDigest(scope *model.SyncScope) string {
if scope == nil || len(scope.Channels) == 0 {
return ""
}
digest := sha256.Sum256([]byte(strings.Join(scope.Channels, "\n")))
return hex.EncodeToString(digest[:8])
}
type Config struct {
SupplyObservation *supply.Tracker
// Empty/zero mean omitted for Go callers; startup config validates explicit values.
ScanConcurrency int
PageSize int
GlobalHighWaterMark int64
ChannelHighWaterMark int64
PollInterval time.Duration
RunLockKey string
RunLockTTL time.Duration
RoundRetryEnabled bool
MaxRounds int
FailureRetention time.Duration
}
func (c Config) withDefaults() Config {
c.RoundRetryEnabled = true
if c.ScanConcurrency == 0 {
c.ScanConcurrency = 4
}
if c.PageSize <= 0 {
c.PageSize = 100
}
if c.PollInterval <= 0 {
c.PollInterval = 2 * time.Second
}
if c.RunLockKey == "" {
c.RunLockKey = "flow:producer:active"
}
if c.RunLockTTL <= 0 {
c.RunLockTTL = 5 * time.Minute
}
if c.MaxRounds <= 0 {
c.MaxRounds = 5
}
if c.FailureRetention <= 0 {
c.FailureRetention = 30 * 24 * time.Hour
}
return c
}
type CardScanner interface {
SnapshotMaxID(context.Context) (int64, error)
}
type Card struct {
ID int64
ICCID string
MSISDN string
IMSI string
ChanKey string
TempKey string
SyncEnabled bool
Operator int
}
type channelMetadata struct {
ChanKey string `orm:"chan_key"`
TempKey string `orm:"temp_key"`
SyncEnabled bool `orm:"sync_enabled"`
Operator int `orm:"operator"`
}
type DatabaseScanner struct{}
func (s *DatabaseScanner) SnapshotMaxID(ctx context.Context) (int64, error) {
var card entity.TSimCardInfo
cardTable := dao.TSimCardInfo.Table()
columns := dao.TSimCardInfo.Columns()
if err := monthlyUsageEligibleCardModel(ctx).
Fields(cardTable + "." + columns.Id + " AS " + columns.Id).
OrderDesc(cardTable + "." + columns.Id).
Limit(1).
Scan(&card); err != nil {
return 0, fmt.Errorf("read card snapshot max id: %w", err)
}
return card.Id, nil
}
func monthlyUsageEligibleCardModel(ctx context.Context) *gdb.Model {
cardTable := dao.TSimCardInfo.Table()
deptTable := dao.SysDept.Table()
cardColumns := dao.TSimCardInfo.Columns()
deptColumns := dao.SysDept.Columns()
return dao.TSimCardInfo.Ctx(ctx).
InnerJoin(deptTable, cardTable+"."+cardColumns.DeptId+" = "+deptTable+"."+deptColumns.DeptId+" AND "+cardTable+"."+cardColumns.TenantId+" = "+deptTable+"."+deptColumns.TenantId).
Where(cardTable+"."+cardColumns.Iccid+" <> ?", "").
Where(cardTable+"."+cardColumns.ChanKey+" <> ?", "").
Where(cardTable+"."+cardColumns.CurrentPackageCode+" <> ?", "").
WhereNull(deptTable + "." + deptColumns.DeletedAt)
}
type Producer struct {
scanner CardScanner
store *zset.Store
config Config
running atomic.Int32
owner string
observation *supply.Observer
}
type productionStats struct {
pages int64
scanned int64
eligible int64
enqueued int64
skippedEmptyChan int64
skippedNoTemplate int64
skippedSyncDisabled int64
skippedOutOfScope int64
skippedChannels int64
stoppedTasks int64
channels map[string]struct{}
scopeChannels int
}
func (s *productionStats) channelCount() int {
return len(s.channels)
}
func (s *productionStats) log(ctx context.Context, status, runID, queryMonth, stage, reason string, startedAt time.Time, maxID int64, err error) {
message := fmt.Sprintf("event=monthly_usage_producer status=%s runId=%s queryMonth=%s scanUpperID=%d pages=%d scanned=%d eligible=%d enqueued=%d skippedEmptyChan=%d skippedNoTemplate=%d skippedSyncDisabled=%d skippedOutOfScope=%d stoppedTasks=%d channelCount=%d scopeChannels=%d durationMs=%d", status, runID, queryMonth, maxID, s.pages, s.scanned, s.eligible, s.enqueued, s.skippedEmptyChan, s.skippedNoTemplate, s.skippedSyncDisabled, s.skippedOutOfScope, s.stoppedTasks, s.channelCount(), s.scopeChannels, time.Since(startedAt).Milliseconds())
message += fmt.Sprintf(" skippedChannels=%d", s.skippedChannels)
if s.scanned > 0 {
message += fmt.Sprintf(" cardsPerSec=%.2f", float64(s.scanned)/time.Since(startedAt).Seconds())
}
if stage != "" {
message += " stage=" + stage
}
if reason != "" {
message += " reason=" + reason
}
if err != nil {
message += fmt.Sprintf(" error=%q", err.Error())
}
if status == "failed" {
g.Log().Error(ctx, message)
return
}
if status == "skipped" {
g.Log().Warning(ctx, message)
return
}
g.Log().Info(ctx, message)
}
func New(scanner CardScanner, store *zset.Store, config Config) (*Producer, error) {
if scanner == nil || store == nil {
return nil, fmt.Errorf("monthly usage producer dependencies are incomplete")
}
config = config.withDefaults()
if err := ValidateScanSettings(config.ScanConcurrency); err != nil {
return nil, err
}
if config.RoundRetryEnabled {
if _, ok := scanner.(channelScanner); !ok {
return nil, fmt.Errorf("channel scan requires ListChannels and ScanChannel capabilities")
}
}
return &Producer{scanner: scanner, store: store, config: config, owner: newOwner()}, nil
}
var ErrAlreadyRunning = fmt.Errorf("monthly usage producer already running")
// ErrRunFinished is returned when a redelivered start message names a run that
// already reached a terminal state. The run must not be resurrected; callers
// should log and ack instead of retrying.
var ErrRunFinished = fmt.Errorf("monthly usage run already finished")
// runAlreadyFinished reports whether runID exists and has already completed.
// A missing run is not an error: the message may be the first delivery.
func (p *Producer) runAlreadyFinished(ctx context.Context, runID string) (bool, error) {
meta, err := p.store.GetRunMeta(ctx, runID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
return false, nil
}
return false, err
}
return meta.Status == "completed" || meta.Status == "completed_with_failures", nil
}
func (p *Producer) Produce(ctx context.Context, message model.TrafficUsageMonthlyStartMessage) (err error) {
startedAt := time.Now()
stats := productionStats{channels: make(map[string]struct{})}
if !p.running.CompareAndSwap(0, 1) {
stats.log(ctx, "skipped", message.RunID, message.QueryMonth, "precheck", "already_running", startedAt, 0, nil)
return ErrAlreadyRunning
}
defer p.running.Store(0)
if message.RunID == "" {
err = fmt.Errorf("runId is empty")
stats.log(ctx, "failed", message.RunID, message.QueryMonth, "validate", "", startedAt, 0, err)
return err
}
scope, err := model.NormalizeScope(message.Scope)
if err != nil {
stats.log(ctx, "failed", message.RunID, message.QueryMonth, "validate", "invalid_scope", startedAt, 0, err)
return err
}
stats.scopeChannels = scope.ChannelCount()
// A redelivered start message must not resurrect a run that already
// finished; that would create a second producer pass over the same RunID.
if p.config.RoundRetryEnabled {
if finished, checkErr := p.runAlreadyFinished(ctx, message.RunID); checkErr != nil {
stats.log(ctx, "failed", message.RunID, message.QueryMonth, "run_state", "", startedAt, 0, checkErr)
return checkErr
} else if finished {
stats.log(ctx, "skipped", message.RunID, message.QueryMonth, "run_state", "run_finished", startedAt, 0, nil)
return ErrRunFinished
}
}
acquired, err := p.store.AcquireLockOrTakeover(ctx, p.config.RunLockKey, p.owner, message.RunID, p.config.RunLockTTL)
if err != nil {
stats.log(ctx, "failed", message.RunID, message.QueryMonth, "lock", "", startedAt, 0, err)
return err
}
if !acquired {
stats.log(ctx, "skipped", message.RunID, message.QueryMonth, "lock", "already_running", startedAt, 0, nil)
return ErrAlreadyRunning
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
defer func() {
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), time.Second)
defer releaseCancel()
_, _ = p.store.ReleaseLock(releaseCtx, p.config.RunLockKey, p.owner)
}()
pending, err := p.hasPendingTasks(ctx, message.RunID)
if err != nil {
stats.log(ctx, "failed", message.RunID, message.QueryMonth, "pending_check", "", startedAt, 0, err)
return err
}
if pending {
stats.log(ctx, "skipped", message.RunID, message.QueryMonth, "pending_check", "pending_tasks", startedAt, 0, nil)
return ErrAlreadyRunning
}
renewErr := make(chan error, 1)
renewDone := make(chan struct{})
go func() {
defer close(renewDone)
p.renewLock(runCtx, cancel, renewErr)
}()
defer func() {
cancel()
<-renewDone
select {
case lockErr := <-renewErr:
err = lockErr
default:
}
}()
ctx = runCtx
if message.Operation == "" {
message.Operation = operation
}
if p.config.RoundRetryEnabled {
if err = p.store.CreateRun(ctx, zset.RunMeta{
RunID: message.RunID,
Operation: message.Operation,
QueryMonth: message.QueryMonth,
Status: "producing",
CurrentRound: 1,
ScopeChannels: scope.ChannelCount(),
ScopeDigest: scopeDigest(scope),
ScopeChannelsSample: strings.Join(scope.ChannelSample(scopeSampleLimit), ","),
}); err != nil {
stats.log(ctx, "failed", message.RunID, message.QueryMonth, "run_init", "", startedAt, 0, err)
return err
}
}
maxID, err := p.scanner.SnapshotMaxID(ctx)
if err != nil {
stats.log(ctx, "failed", message.RunID, message.QueryMonth, "snapshot", "", startedAt, 0, err)
return err
}
g.Log().Infof(ctx, "event=monthly_usage_producer status=started runId=%s queryMonth=%s scanUpperID=%d scopeChannels=%d scanMode=channel scanConcurrency=%d pageSize=%d", message.RunID, message.QueryMonth, maxID, scope.ChannelCount(), p.config.ScanConcurrency, p.config.PageSize)
stage := "scan"
defer func() {
if err != nil {
stats.log(ctx, "failed", message.RunID, message.QueryMonth, stage, "", startedAt, maxID, err)
return
}
stats.log(ctx, "completed", message.RunID, message.QueryMonth, "", "", startedAt, maxID, nil)
}()
if p.config.RoundRetryEnabled {
stage = "channel_scan"
if err = p.produceChannels(ctx, message, scope, maxID, startedAt, &stats); err != nil {
if isProductionClosed(err) {
// A same-Run redelivery after producerDone leaves consumption alone.
return nil
}
return err
}
// Verify ownership immediately before closing production. The periodic
// renewer still cancels scanners and capacity waits on lease loss.
var owned bool
owned, err = p.store.RenewLock(ctx, p.config.RunLockKey, p.owner, p.config.RunLockTTL)
if err != nil {
return err
}
if !owned {
return fmt.Errorf("producer lock token is no longer owned")
}
if err = ctx.Err(); err != nil {
return err
}
stage = "producer_done"
err = p.store.MarkProducerDone(ctx, message.RunID)
return err
}
return fmt.Errorf("monthly usage full sync requires Run queue; legacy serial production has been removed")
}
// isRunNotFound reports whether err means the run's metadata hash is absent, as
// opposed to a genuine read failure.
func isRunNotFound(err error) bool {
return errors.Is(err, zset.ErrRunNotFound)
}
func (p *Producer) hasPendingTasks(ctx context.Context, currentRunID ...string) (bool, error) {
currentID := ""
if len(currentRunID) > 0 {
currentID = currentRunID[0]
}
if p.config.RoundRetryEnabled {
runs, err := p.store.ActiveRuns(ctx)
if err != nil {
return false, err
}
for _, runID := range runs {
if runID == currentID {
// A redelivered message for the same run may resume a producer
// that stopped before producerDone. Stable task IDs make the
// rescan idempotent; other runs still provide backpressure.
continue
}
meta, metaErr := p.store.GetRunMeta(ctx, runID)
if metaErr != nil {
// The active index is a global key written outside the run-scoped
// Lua transaction, so it can list a run whose hash is already gone
// (expired, evicted, or cleaned by hand). Returning an error here
// would surface as a non-retryable failure and make the consumer
// redeliver this request forever. Treat it as a stale index entry
// and drop it.
if isRunNotFound(metaErr) {
if _, removeErr := p.store.RedisClient().Do(ctx, "SREM", p.store.ActiveRunsKey(), runID); removeErr != nil {
return false, removeErr
}
g.Log().Warningf(ctx, "event=monthly_usage_producer status=skipped stage=pending_check reason=stale_index runId=%s", runID)
continue
}
return false, metaErr
}
// A "running" run has already finished producing (producerDone=1)
// and the consumer owns the remaining work: it is genuinely in-flight
// and must keep providing backpressure.
if meta.Status == "running" {
return true, nil
}
// A "producing" run for another ID cannot have a live producer. This
// function only runs after AcquireLockOrTakeover succeeded on the
// single producer lock, so any live producer would still hold it and
// we could not have acquired it. Such a run is a zombie left behind by
// a producer that died before MarkProducerDone (for example the
// process was replaced mid-run).
//
// Promote it so the normal lifecycle can drain and close it. Blocking
// would reject every later request forever, and merely warning would
// leak the run's keys and its active-index entry indefinitely.
reclaimed, reclaimErr := p.store.MarkProducerAbandoned(ctx, runID)
if reclaimErr != nil {
return false, reclaimErr
}
g.Log().Warningf(ctx, "event=monthly_usage_producer status=skipped stage=pending_check reason=zombie_run runId=%s reclaimed=%t activeCount=%d inflightCount=%d nextRoundCount=%d", runID, reclaimed, meta.ActiveCount, meta.InflightCount, meta.NextRoundCount)
}
}
channels, err := p.store.ActiveChannels(ctx)
if err != nil {
return false, err
}
for _, chanKey := range channels {
size, sizeErr := p.store.Size(ctx, chanKey)
if sizeErr != nil {
return false, sizeErr
}
if size > 0 {
return true, nil
}
}
return false, nil
}
func (p *Producer) renewLock(ctx context.Context, cancel context.CancelFunc, errors chan<- error) {
interval := p.config.RunLockTTL / 3
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ok, err := p.store.RenewLock(ctx, p.config.RunLockKey, p.owner, p.config.RunLockTTL)
if err != nil || !ok {
// Cancellation during a renewal RPC is shutdown, not lease loss.
if ctx.Err() != nil {
return
}
if err == nil {
err = fmt.Errorf("producer lock token is no longer owned")
}
select {
case errors <- fmt.Errorf("renew producer lock: %w", err):
default:
}
cancel()
return
}
}
}
}
func newOwner() string {
var bytes [12]byte
if _, err := rand.Read(bytes[:]); err == nil {
return "producer-" + hex.EncodeToString(bytes[:])
}
return fmt.Sprintf("producer-%d", time.Now().UnixNano())
}
func sleepContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
@@ -0,0 +1,168 @@
package monthlyusage
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"github.com/gogf/gf/v2/container/gvar"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
)
type pendingRedis struct {
channels []string
sizes map[string]int64
failKey string
}
func (r *pendingRedis) Do(_ context.Context, command string, args ...any) (*gvar.Var, error) {
switch strings.ToUpper(command) {
case "SMEMBERS":
return gvar.New(r.channels), nil
case "ZCARD":
key := fmt.Sprint(args[0])
if key == r.failKey {
return nil, fmt.Errorf("redis unavailable")
}
return gvar.New(r.sizes[key]), nil
default:
return nil, fmt.Errorf("unexpected command %s", command)
}
}
func (r *pendingRedis) Eval(_ context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
return nil, fmt.Errorf("unexpected Eval call")
}
func TestHasPendingTasks(t *testing.T) {
tests := []struct {
name string
redis *pendingRedis
want bool
wantErr bool
}{
{
name: "all registered channels are empty",
redis: &pendingRedis{
channels: []string{"mobile-a", "telecom-a"},
sizes: map[string]int64{
"flow:mobile-a": 0,
"flow:telecom-a": 0,
},
},
},
{
name: "previous run still has tasks",
redis: &pendingRedis{
channels: []string{"mobile-a", "telecom-a"},
sizes: map[string]int64{
"flow:mobile-a": 0,
"flow:telecom-a": 3,
},
},
want: true,
},
{
name: "redis error is not treated as empty",
redis: &pendingRedis{
channels: []string{"mobile-a"},
sizes: map[string]int64{},
failKey: "flow:mobile-a",
},
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store, err := zset.New(test.redis, "flow:")
if err != nil {
t.Fatal(err)
}
producer, err := New(&fakeScanner{}, store, Config{})
if err != nil {
t.Fatal(err)
}
got, err := func() (bool, error) {
producer.config.RoundRetryEnabled = false
return producer.hasPendingTasks(context.Background())
}()
if (err != nil) != test.wantErr {
t.Fatalf("hasPendingTasks() error = %v, wantErr %v", err, test.wantErr)
}
if got != test.want {
t.Fatalf("hasPendingTasks() = %v, want %v", got, test.want)
}
})
}
}
type fakeScanner struct{}
func (*fakeScanner) SnapshotMaxID(context.Context) (int64, error) { return 0, nil }
func (*fakeScanner) Scan(context.Context, int64, int64, int) ([]Card, error) {
return nil, nil
}
func (*fakeScanner) ListChannels(context.Context) ([]channelMetadata, error) { return nil, nil }
func (*fakeScanner) ScanChannel(context.Context, string, int64, int) ([]Card, error) { return nil, nil }
type pageScanner struct {
cards []Card
}
func (s *pageScanner) SnapshotMaxID(context.Context) (int64, error) {
if len(s.cards) == 0 {
return 0, nil
}
return s.cards[len(s.cards)-1].ID, nil
}
func (s *pageScanner) Scan(_ context.Context, lastID, _ int64, _ int) ([]Card, error) {
if lastID != 0 {
return nil, nil
}
return s.cards, nil
}
type producerRedis struct {
mu sync.Mutex
enqueueBatches map[string]int
}
func (r *producerRedis) Do(_ context.Context, command string, _ ...any) (*gvar.Var, error) {
switch strings.ToUpper(command) {
case "SET":
return gvar.New("OK"), nil
case "SMEMBERS":
return gvar.New([]string{}), nil
case "HGETALL":
// No stored run metadata: the producer treats this as a first delivery.
return gvar.New(map[string]string{}), nil
default:
return nil, fmt.Errorf("unexpected command %s", command)
}
}
func (r *producerRedis) Eval(_ context.Context, _ string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
if numKeys == 1 {
return gvar.New(int64(1)), nil
}
if numKeys != 3 {
return nil, fmt.Errorf("unexpected key count %d", numKeys)
}
r.mu.Lock()
r.enqueueBatches[keys[0]] = len(args) - 3
r.mu.Unlock()
return gvar.New(len(args) - 3), nil
}
func (s *pageScanner) ListChannels(ctx context.Context) ([]channelMetadata, error) {
return scannerFor(s.cards, 0).ListChannels(ctx)
}
func (s *pageScanner) ScanChannel(ctx context.Context, key string, last int64, size int) ([]Card, error) {
return scannerFor(s.cards, 0).ScanChannel(ctx, key, last, size)
}
@@ -0,0 +1,249 @@
package monthlyusage
import (
"context"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func newScopeProducer(t *testing.T, cards []Card) (*Producer, *producerRedis) {
t.Helper()
store := testStore(t)
scanner := scannerFor(cards, cards[len(cards)-1].ID)
for i := range scanner.channels {
for _, c := range cards {
if c.ChanKey == scanner.channels[i].ChanKey {
scanner.channels[i].TempKey = c.TempKey
scanner.channels[i].SyncEnabled = c.SyncEnabled
break
}
}
}
p, err := New(scanner, store, Config{PageSize: 100})
if err != nil {
t.Fatal(err)
}
return p, &producerRedis{enqueueBatches: map[string]int{}}
}
func scopedMessage(runID string, channels ...string) model.TrafficUsageMonthlyStartMessage {
return model.TrafficUsageMonthlyStartMessage{
RunID: runID,
Operation: "current_month_usage_full_sync",
QueryMonth: "202608",
Scope: &model.SyncScope{Channels: channels},
}
}
func TestProduceScopeEnqueuesOnlySelectedChannels(t *testing.T) {
producer, redis := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
{ID: 2, ChanKey: "173", TempKey: "sim_yd_v5", Operator: 1},
{ID: 3, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
{ID: 4, ChanKey: "189", TempKey: "sim_yd_v5", Operator: 1},
})
if err := producer.Produce(context.Background(), scopedMessage("scoped", "217")); err != nil {
t.Fatal(err)
}
redis.enqueueBatches = map[string]int{}
for _, ch := range []string{"217", "173", "189", "ghost-channel", "a", "b", "mobile-a", "mobile-b"} {
key, _ := producer.store.RoundActiveKey("scoped", 1, ch)
n, _ := producer.store.SizeKey(context.Background(), key)
if n > 0 {
redis.enqueueBatches["flow:"+ch] = int(n)
}
}
redis.mu.Lock()
defer redis.mu.Unlock()
if len(redis.enqueueBatches) != 1 {
t.Fatalf("enqueue batches = %+v, want only the 217 channel", redis.enqueueBatches)
}
if redis.enqueueBatches["flow:217"] != 2 {
t.Fatalf("enqueue batches = %+v, want 2 tasks on flow:217", redis.enqueueBatches)
}
}
func TestProduceScopeMultipleChannels(t *testing.T) {
producer, redis := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
{ID: 2, ChanKey: "173", TempKey: "sim_yd_v5", Operator: 1},
{ID: 3, ChanKey: "189", TempKey: "sim_yd_v5", Operator: 1},
})
if err := producer.Produce(context.Background(), scopedMessage("scoped", "189", "217")); err != nil {
t.Fatal(err)
}
redis.enqueueBatches = map[string]int{}
for _, ch := range []string{"217", "173", "189", "ghost-channel", "a", "b", "mobile-a", "mobile-b"} {
key, _ := producer.store.RoundActiveKey("scoped", 1, ch)
n, _ := producer.store.SizeKey(context.Background(), key)
if n > 0 {
redis.enqueueBatches["flow:"+ch] = int(n)
}
}
redis.mu.Lock()
defer redis.mu.Unlock()
if len(redis.enqueueBatches) != 2 {
t.Fatalf("enqueue batches = %+v, want 217 and 189", redis.enqueueBatches)
}
if _, ok := redis.enqueueBatches["flow:173"]; ok {
t.Fatalf("channel 173 was enqueued despite not being selected: %+v", redis.enqueueBatches)
}
}
func TestProduceFullScopeKeepsEveryChannel(t *testing.T) {
producer, redis := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
{ID: 2, ChanKey: "173", TempKey: "sim_yd_v5", Operator: 1},
})
if err := producer.Produce(context.Background(), model.TrafficUsageMonthlyStartMessage{
RunID: "full",
Operation: "current_month_usage_full_sync",
QueryMonth: "202608",
}); err != nil {
t.Fatal(err)
}
redis.enqueueBatches = map[string]int{}
for _, ch := range []string{"217", "173", "189", "ghost-channel", "a", "b", "mobile-a", "mobile-b"} {
key, _ := producer.store.RoundActiveKey("full", 1, ch)
n, _ := producer.store.SizeKey(context.Background(), key)
if n > 0 {
redis.enqueueBatches["flow:"+ch] = int(n)
}
}
redis.mu.Lock()
defer redis.mu.Unlock()
if len(redis.enqueueBatches) != 2 {
t.Fatalf("enqueue batches = %+v, want both channels for a full sync", redis.enqueueBatches)
}
}
func TestProduceScopeCombinesWithSyncDisabledGuard(t *testing.T) {
producer, redis := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
// Selected but excluded by sync_enabled: scope must not override it.
{ID: 2, ChanKey: "173", TempKey: "sim_yd_v5", SyncEnabled: true, Operator: 1},
})
if err := producer.Produce(context.Background(), scopedMessage("scoped", "173")); err != nil {
t.Fatal(err)
}
redis.enqueueBatches = map[string]int{}
for _, ch := range []string{"217", "173", "189", "ghost-channel", "a", "b", "mobile-a", "mobile-b"} {
key, _ := producer.store.RoundActiveKey("scoped", 1, ch)
n, _ := producer.store.SizeKey(context.Background(), key)
if n > 0 {
redis.enqueueBatches["flow:"+ch] = int(n)
}
}
redis.mu.Lock()
defer redis.mu.Unlock()
if len(redis.enqueueBatches) != 0 {
t.Fatalf("enqueue batches = %+v, want none (selected channel is sync-disabled)", redis.enqueueBatches)
}
}
func TestProduceScopeSelectingUnknownChannelEnqueuesNothing(t *testing.T) {
producer, redis := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
})
if err := producer.Produce(context.Background(), scopedMessage("scoped", "does-not-exist")); err != nil {
t.Fatal(err)
}
redis.enqueueBatches = map[string]int{}
for _, ch := range []string{"217", "173", "189", "ghost-channel", "a", "b", "mobile-a", "mobile-b"} {
key, _ := producer.store.RoundActiveKey("scoped", 1, ch)
n, _ := producer.store.SizeKey(context.Background(), key)
if n > 0 {
redis.enqueueBatches["flow:"+ch] = int(n)
}
}
redis.mu.Lock()
defer redis.mu.Unlock()
if len(redis.enqueueBatches) != 0 {
t.Fatalf("enqueue batches = %+v, want none", redis.enqueueBatches)
}
}
func TestProduceRejectsInvalidScope(t *testing.T) {
producer, redis := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
})
if err := producer.Produce(context.Background(), scopedMessage("bad", "invalid/chan")); err == nil {
t.Fatal("expected invalid chanKey to be rejected")
}
redis.enqueueBatches = map[string]int{}
for _, ch := range []string{"217", "173", "189", "ghost-channel", "a", "b", "mobile-a", "mobile-b"} {
key, _ := producer.store.RoundActiveKey("scoped", 1, ch)
n, _ := producer.store.SizeKey(context.Background(), key)
if n > 0 {
redis.enqueueBatches["flow:"+ch] = int(n)
}
}
redis.mu.Lock()
defer redis.mu.Unlock()
if len(redis.enqueueBatches) != 0 {
t.Fatalf("rejected scope must not enqueue: %+v", redis.enqueueBatches)
}
}
func TestProduceScopeDeduplicatesChannels(t *testing.T) {
producer, redis := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
})
if err := producer.Produce(context.Background(), scopedMessage("scoped", "217", "217")); err != nil {
t.Fatal(err)
}
redis.enqueueBatches = map[string]int{}
for _, ch := range []string{"217", "173", "189", "ghost-channel", "a", "b", "mobile-a", "mobile-b"} {
key, _ := producer.store.RoundActiveKey("scoped", 1, ch)
n, _ := producer.store.SizeKey(context.Background(), key)
if n > 0 {
redis.enqueueBatches["flow:"+ch] = int(n)
}
}
redis.mu.Lock()
defer redis.mu.Unlock()
if redis.enqueueBatches["flow:217"] != 1 {
t.Fatalf("enqueue batches = %+v, want 1 task despite a duplicated chanKey", redis.enqueueBatches)
}
}
func TestScopeDigestIsOrderIndependent(t *testing.T) {
first := &model.SyncScope{Channels: []string{"173", "217"}}
second := &model.SyncScope{Channels: []string{"217", "173"}}
// Normalization sorts the channels, so both requests produce one digest.
normalizedFirst, err := model.NormalizeScope(first)
if err != nil {
t.Fatal(err)
}
normalizedSecond, err := model.NormalizeScope(second)
if err != nil {
t.Fatal(err)
}
if scopeDigest(normalizedFirst) != scopeDigest(normalizedSecond) {
t.Fatalf("digest depends on channel order: %s vs %s", scopeDigest(normalizedFirst), scopeDigest(normalizedSecond))
}
if scopeDigest(nil) != "" {
t.Fatalf("full sync must have an empty digest")
}
}
func TestRunAlreadyFinishedTreatsMissingRunAsFirstDelivery(t *testing.T) {
producer, _ := newScopeProducer(t, []Card{
{ID: 1, ChanKey: "217", TempKey: "sim_yd_v5", Operator: 1},
})
// The fake Redis returns no metadata, which must not be treated as finished.
finished, err := producer.runAlreadyFinished(context.Background(), "fresh")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if finished {
t.Fatal("a run without stored metadata must not be reported as finished")
}
}
@@ -0,0 +1,90 @@
package monthlyusage
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
"github.com/gogf/gf/v2/frame/g"
"time"
)
func (p *Producer) observe(key, stage string, pending int, added, ended bool) {
if p.observation != nil {
p.observation.Event(key, stage, pending, added, ended, time.Now())
}
}
func (p *Producer) startObservation(ctx context.Context, run string, keys []string, started time.Time) func(bool) {
tracker := p.config.SupplyObservation
if tracker == nil {
return func(bool) {}
}
o := supply.New(newOwner(), started, keys)
p.observation = o
readCtx, readCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
meta, err := p.store.GetRunMeta(readCtx, run)
readCancel()
if err != nil {
o.Partial()
} else if meta.SupplyObservation != nil {
o.Restore(*meta.SupplyObservation)
}
tracker.Put(run, o)
stop := make(chan struct{})
done := make(chan struct{})
flush := func() {
writeCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
encoded, e := o.Encode()
if e == nil {
var saved bool
saved, e = p.store.SaveSupply(writeCtx, run, p.config.RunLockKey, p.owner, encoded)
if !saved && e == nil {
o.Partial()
}
}
if e != nil {
o.Partial()
g.Log().Warningf(writeCtx, "event=supply_observation status=partial runId=%s error=%q", run, e.Error())
}
}
go func() {
defer close(done)
sampleTicker := time.NewTicker(time.Second)
defer sampleTicker.Stop()
flushTicker := time.NewTicker(10 * time.Second)
defer flushTicker.Stop()
for {
select {
case <-stop:
return
case <-ctx.Done():
o.Partial()
return
case <-flushTicker.C:
flush()
case <-sampleTicker.C:
views := o.Views()
sampleCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
samples, e := p.store.ReadSupply(sampleCtx, run, keys, time.Now())
cancel()
now := time.Now()
for _, key := range keys {
s := samples[key]
if e != nil || !tracker.LocalConsumer || !tracker.ConsumerActive.Load() {
s.Known = false
}
o.Apply(key, views[key], s, now)
}
}
}
}()
return func(success bool) {
close(stop)
<-done
if !success {
o.Partial()
}
flush()
tracker.Remove(run)
p.observation = nil
}
}
@@ -0,0 +1,69 @@
package monthlyusage
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
"github.com/gogf/gf/v2/container/gvar"
"strings"
"testing"
"time"
)
type telemetryFailRedis struct{ zset.RedisClient }
func (r telemetryFailRedis) Eval(ctx context.Context, script string, n int64, keys []string, args []any) (*gvar.Var, error) {
if strings.Contains(script, "'supplyObservation'") {
return nil, fmt.Errorf("injected telemetry write failure")
}
return r.RedisClient.Eval(ctx, script, n, keys, args)
}
func TestSupplyFlushFailureDoesNotBlockProducerDone(t *testing.T) {
client := testutil.Redis(t)
store, err := zset.New(telemetryFailRedis{client}, "flow:")
if err != nil {
t.Fatal(err)
}
scanner := scannerFor([]Card{{ID: 1, ChanKey: "a"}}, 1)
p := testProducer(t, scanner, store, Config{SupplyObservation: supply.NewTracker(true)})
if err = p.Produce(context.Background(), model.TrafficUsageMonthlyStartMessage{RunID: "telemetry-failure"}); err != nil {
t.Fatal(err)
}
requireMeta(t, store, "telemetry-failure", true, 1)
}
func TestSupplyObservesEmptyTargetAndFinalFlush(t *testing.T) {
store := testStore(t)
scanner := scannerFor([]Card{{ID: 1, ChanKey: "a"}}, 2)
scanner.channels = append(scanner.channels, channelMetadata{ChanKey: "empty", TempKey: "sim_yd_v5"})
tracker := supply.NewTracker(true)
tracker.ConsumerActive.Store(true)
scanner.hook = func(ctx context.Context, key string, last int64) error {
if key == "empty" {
return sleepContext(ctx, 2200*time.Millisecond)
}
return nil
}
p := testProducer(t, scanner, store, Config{SupplyObservation: tracker})
if err := p.Produce(context.Background(), model.TrafficUsageMonthlyStartMessage{RunID: "supply-end", QueryMonth: "202610"}); err != nil {
t.Fatal(err)
}
m, err := store.GetRunMeta(context.Background(), "supply-end")
if err != nil {
t.Fatal(err)
}
if !m.ProducerDone || m.SupplyObservation == nil {
t.Fatalf("missing final observation %+v", m)
}
snapshot := m.SupplyObservation
if snapshot.Summary.Targets != 2 || snapshot.Summary.Confirmed != 0 {
t.Fatalf("bad summary %+v", snapshot)
}
c := snapshot.Channels[1]
if c.Key != "empty" || c.FirstEnqueueMS != nil || c.Termination != "empty_result" || c.Unconfirmed.TotalMS < 900 {
t.Fatalf("bad empty target %+v", c)
}
}
@@ -0,0 +1,174 @@
package monthlyusage
import (
"context"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
func newZombieTestProducer(t *testing.T) (*Producer, *zset.Store) {
t.Helper()
store, err := zset.New(testutil.Redis(t), "flow:")
if err != nil {
t.Fatal(err)
}
producer, err := New(&pageScanner{cards: nil}, store, Config{PageSize: 100, RoundRetryEnabled: true})
if err != nil {
t.Fatal(err)
}
return producer, store
}
// TestHasPendingTasksReclaimsZombieRun guards the "zombie run" regression.
//
// A producer that dies before MarkProducerDone (for example the container is
// replaced mid-run) leaves its run in status=producing with producerDone=0.
// completeRunIfIdle and advanceRunRound both require producerDone=1, so such a
// run can never be closed, never leaves the active index, and never gets the
// retention TTL. Treating it as backpressure rejected every later request
// forever; merely warning about it leaked keys and an index entry indefinitely.
func TestHasPendingTasksReclaimsZombieRun(t *testing.T) {
producer, store := newZombieTestProducer(t)
ctx := context.Background()
// A genuinely in-flight run (producerDone set, consumer working) must keep
// providing backpressure.
if err := store.CreateRun(ctx, zset.RunMeta{RunID: "running-run", Status: "running", ProducerDone: true, CurrentRound: 1}); err != nil {
t.Fatal(err)
}
pending, err := producer.hasPendingTasks(ctx, "new-run")
if err != nil {
t.Fatal(err)
}
if !pending {
t.Fatal("a running run must still provide backpressure")
}
if _, err := store.RedisClient().Do(ctx, "SREM", store.ActiveRunsKey(), "running-run"); err != nil {
t.Fatal(err)
}
// A zombie run must not block a new request, and must be promoted so the
// normal lifecycle can drain and close it.
if err := store.CreateRun(ctx, zset.RunMeta{RunID: "zombie-run", Status: "producing", ProducerDone: false, CurrentRound: 1}); err != nil {
t.Fatal(err)
}
pending, err = producer.hasPendingTasks(ctx, "new-run-2")
if err != nil {
t.Fatal(err)
}
if pending {
t.Fatal("a producing run with producerDone=0 must not block new requests (zombie run)")
}
meta, err := store.GetRunMeta(ctx, "zombie-run")
if err != nil {
t.Fatal(err)
}
if !meta.ProducerDone {
t.Fatal("the zombie run must be promoted (producerDone=1) so it can be closed")
}
if meta.Status != "running" {
t.Fatalf("promoted run status = %q, want running", meta.Status)
}
// Promotion makes the run closable, which is what applies the retention TTL
// and releases the active index entry.
if completed, err := store.CompleteRunIfIdle(ctx, "zombie-run"); err != nil || !completed {
t.Fatalf("promoted run must be closable: completed=%v err=%v", completed, err)
}
}
// TestHasPendingTasksNeverReclaimsReplayRun guards the boundary: a replay target
// is also created as "producing", but it is driven by the replay engine under
// its own lock, so its producerDone is not the producer's to set.
func TestHasPendingTasksNeverReclaimsReplayRun(t *testing.T) {
producer, store := newZombieTestProducer(t)
ctx := context.Background()
if err := store.CreateRun(ctx, zset.RunMeta{
RunID: "replay-run", Status: "producing", ProducerDone: false, CurrentRound: 1,
SyncType: "replay", SourceRunID: "source-run", ReplayCycle: 1,
}); err != nil {
t.Fatal(err)
}
if _, err := producer.hasPendingTasks(ctx, "new-run"); err != nil {
t.Fatal(err)
}
meta, err := store.GetRunMeta(ctx, "replay-run")
if err != nil {
t.Fatal(err)
}
if meta.ProducerDone {
t.Fatal("a replay run must not be promoted by the full-sync producer")
}
if meta.Status != "producing" {
t.Fatalf("replay run status = %q, want producing", meta.Status)
}
}
// TestHasPendingTasksDropsStaleIndexEntry covers a second unrecoverable path:
// the active index can list a run whose hash is already gone. Returning that as
// an error made the request fail permanently (the consumer redelivers it
// forever), which is the same "only fixable by hand-cleaning Redis" failure.
func TestHasPendingTasksDropsStaleIndexEntry(t *testing.T) {
producer, store := newZombieTestProducer(t)
ctx := context.Background()
// Index a run that has no metadata hash.
if _, err := store.RedisClient().Do(ctx, "SADD", store.ActiveRunsKey(), "ghost-run"); err != nil {
t.Fatal(err)
}
pending, err := producer.hasPendingTasks(ctx, "new-run")
if err != nil {
t.Fatalf("a stale index entry must not fail the request: %v", err)
}
if pending {
t.Fatal("a stale index entry must not provide backpressure")
}
// The stale entry is removed so it does not have to be handled again.
members, err := store.ActiveRuns(ctx)
if err != nil {
t.Fatal(err)
}
for _, member := range members {
if member == "ghost-run" {
t.Fatal("the stale index entry must be removed")
}
}
}
// TestProduceCreatesRunAfterZombie covers the end-to-end path through Produce:
// a zombie run must not stop a new run from being created.
func TestProduceCreatesRunAfterZombie(t *testing.T) {
store, err := zset.New(testutil.Redis(t), "flow:")
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
producer, err := New(&pageScanner{cards: []Card{
{ID: 1, ChanKey: "channel-a", TempKey: "sim_yd_v5", Operator: 1},
}}, store, Config{PageSize: 100, RoundRetryEnabled: true})
if err != nil {
t.Fatal(err)
}
// A zombie run from a crashed producer.
if err := store.CreateRun(ctx, zset.RunMeta{RunID: "zombie-run", Status: "producing", ProducerDone: false, CurrentRound: 1}); err != nil {
t.Fatal(err)
}
// A new request must still produce its own run.
if err := producer.Produce(ctx, scopedMessage("fresh-run", "channel-a")); err != nil {
t.Fatalf("a zombie run must not stop a new run: %v", err)
}
meta, err := store.GetRunMeta(ctx, "fresh-run")
if err != nil {
t.Fatal(err)
}
// The producer enqueues into the active ZSet; ActiveCount is the count it
// maintains. EnqueuedCount is an accounting field the producer does not set.
if !meta.ProducerDone || meta.ActiveCount != 1 {
t.Fatalf("fresh run metadata = %+v, want producerDone with 1 active task", meta)
}
}