chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
@@ -0,0 +1,95 @@
package imei
import (
"context"
"strings"
"time"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/errors/gerror"
goredis "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
)
type leaseClientFactory func() (queue.RedisClient, func() error, error)
// A custom client must explicitly supply the same bounded-I/O contract. There
// is no fallback to context cancellation on an unknown Redis implementation.
type boundedLeaseProvider interface {
newBoundedLeaseClient(time.Duration) (queue.RedisClient, func() error, error)
}
func newLeaseClientFactory(client queue.RedisClient, config *gredis.Config, budget time.Duration) (leaseClientFactory, error) {
if budget <= 0 {
return nil, gerror.New("IMEI lease I/O budget must be positive")
}
if provider, ok := client.(boundedLeaseProvider); ok {
return func() (queue.RedisClient, func() error, error) { return provider.newBoundedLeaseClient(budget) }, nil
}
getter, ok := client.(interface{ GetAdapter() gredis.Adapter })
if !ok || getter.GetAdapter() == nil {
return nil, gerror.New("IMEI producer requires a bounded GoFrame Redis adapter")
}
raw, ok := getter.GetAdapter().Client().(*goredis.Client)
if !ok {
return nil, gerror.New("IMEI producer does not support Redis Cluster or an unknown Redis adapter")
}
opts := *raw.Options()
if opts.Addr == "FailoverClient" {
if config == nil || config.MasterName == "" || config.Cluster || config.SlaveOnly {
return nil, gerror.New("IMEI Sentinel lease client requires its master configuration")
}
// Rebuild Sentinel discovery too: sharing the old dialer would retain
// its unbounded Sentinel socket reads even if master I/O were bounded.
cfg := *config
return func() (queue.RedisClient, func() error, error) {
client := goredis.NewFailoverClient(&goredis.FailoverOptions{
MasterName: cfg.MasterName, SentinelAddrs: sentinelAddresses(cfg.Address),
SentinelUsername: cfg.SentinelUser, SentinelPassword: cfg.SentinelPass,
Username: cfg.User, Password: cfg.Pass, DB: cfg.Db, Protocol: cfg.Protocol,
TLSConfig: opts.TLSConfig,
DialTimeout: budget, ReadTimeout: budget, WriteTimeout: budget, PoolTimeout: budget,
ContextTimeoutEnabled: true, MaxRetries: -1, DialerRetries: 1,
PoolSize: 1, MaxActiveConns: 1,
})
return &leaseRedis{client}, client.Close, nil
}, nil
}
// Own a separate pool so a timeout/close never closes a consumer's socket.
opts.DialTimeout, opts.ReadTimeout, opts.WriteTimeout, opts.PoolTimeout = budget, budget, budget, budget
opts.ContextTimeoutEnabled = true
// Maintenance handoffs can relax socket deadlines; leases must never do so.
opts.MaintNotificationsConfig = &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}
opts.MaxRetries, opts.DialerRetries = -1, 1
opts.PoolSize, opts.MaxActiveConns, opts.MinIdleConns, opts.MaxIdleConns = 1, 1, 0, 1
opts.Dialer = goredis.NewDialer(&opts)
return func() (queue.RedisClient, func() error, error) {
client := goredis.NewClient(&opts)
return &leaseRedis{client}, client.Close, nil
}, nil
}
func sentinelAddresses(address string) []string {
var addresses []string
for _, addr := range strings.Split(address, ",") {
if addr = strings.TrimSpace(addr); addr != "" {
addresses = append(addresses, addr)
}
}
return addresses
}
type leaseRedis struct{ client *goredis.Client }
func (r *leaseRedis) Do(ctx context.Context, command string, args ...any) (*gvar.Var, error) {
result, err := r.client.Do(ctx, append([]any{command}, args...)...).Result()
return gvar.New(result), err
}
func (r *leaseRedis) Eval(ctx context.Context, script string, _ int64, keys []string, args []any) (*gvar.Var, error) {
result, err := r.client.Eval(ctx, script, keys, args...).Result()
return gvar.New(result), err
}
@@ -0,0 +1,576 @@
package imei
import (
"bufio"
"context"
"errors"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gogf/gf/v2/database/gredis"
goredis "github.com/redis/go-redis/v9"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
// This proxy withholds a real Redis request/reply without closing its TCP socket.
func readLeaseRESP(r *bufio.Reader) ([]byte, error) {
line, err := r.ReadBytes('\n')
if err != nil {
return nil, err
}
switch line[0] {
case '$':
n, err := strconv.Atoi(strings.TrimSpace(string(line[1:])))
if err != nil {
return nil, err
}
if n >= 0 {
payload := make([]byte, n+2)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, err
}
line = append(line, payload...)
}
case '*', '%', '~', '>', '|':
n, err := strconv.Atoi(strings.TrimSpace(string(line[1:])))
if err != nil {
return nil, err
}
if line[0] == '%' || line[0] == '|' {
n *= 2
}
for i := 0; i < n; i++ {
payload, err := readLeaseRESP(r)
if err != nil {
return nil, err
}
line = append(line, payload...)
}
}
return line, nil
}
func TestProduceBoundsRealRedisRenewalIO(t *testing.T) {
for _, mode := range []string{"standalone", "sentinel"} {
t.Run(mode, func(t *testing.T) { testProduceBoundsRealRedisRenewalIO(t, mode) })
}
}
func testProduceBoundsRealRedisRenewalIO(t *testing.T, mode string) {
for _, callerCancel := range []bool{false, true} {
t.Run(strconv.FormatBool(callerCancel), func(t *testing.T) {
backend := testutil.Redis(t)
real, held, release := leaseRedisProxy(t, backend, "redis.call('PEXPIRE'", true)
// Release the gate even on a red-test failure so no worker leaks.
defer release()
opts := real.GetAdapter().Client().(*goredis.Client).Options()
if opts.ReadTimeout != 0 || opts.WriteTimeout != 0 || opts.ContextTimeoutEnabled {
t.Fatal("test must start with production-default unbounded socket I/O")
}
cause := errors.New("snapshot failed independently")
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
scanner := &lockTestScanner{snapshot: func(workCtx context.Context) (int64, error) {
select {
case <-held:
if callerCancel {
cancel(cause)
<-workCtx.Done()
return 0, workCtx.Err()
}
return 0, cause
case <-workCtx.Done():
return 0, workCtx.Err()
}
}}
store, err := queue.New(real, "imei:")
if err != nil {
t.Fatal(err)
}
config := Config{RunLockTTL: time.Second}
if mode == "sentinel" {
real, config.LeaseRedisConfig = sentinelLeaseRedis(t, real)
store, err = queue.New(real, "imei:")
if err != nil {
t.Fatal(err)
}
}
producer, err := New(scanner, store, config)
if err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() { result <- producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "lock-test"}) }()
select {
case <-held:
case <-time.After(2 * time.Second):
t.Fatal("proxy did not hold renewal response")
}
select {
case err := <-result:
if !errors.Is(err, cause) {
t.Fatalf("lost original cause: %v", err)
}
case <-time.After(600 * time.Millisecond):
release()
<-result
t.Fatal("canceled renewal socket I/O blocked cleanup beyond its lease budget")
}
if producer.running.Load() != 0 {
t.Fatal("running flag was not reset")
}
lock, err := backend.Do(context.Background(), "GET", producer.config.RunLockKey)
if err != nil || !lock.IsNil() {
t.Fatalf("lock not released: %v, %v", lock, err)
}
if opts.ReadTimeout != 0 || opts.WriteTimeout != 0 {
t.Fatal("lease client mutated the shared production client")
}
// A fresh run must start without opening the stalled gate.
producer.scanner = cardScanner{}
if err := producer.Produce(context.Background(), model.ImeiQueueStartMessage{RunID: "next-run"}); err != nil {
t.Fatalf("producer cannot restart: %v", err)
}
})
}
}
func TestProduceFencesDelayedRealRedisWrites(t *testing.T) {
for _, mode := range []string{"standalone", "sentinel"} {
t.Run(mode, func(t *testing.T) { testProduceFencesDelayedRealRedisWrites(t, mode) })
}
}
func testProduceFencesDelayedRealRedisWrites(t *testing.T, mode string) {
for _, stage := range []string{"enqueue", "producer_done"} {
t.Run(stage, func(t *testing.T) {
backend := testutil.Redis(t)
match := "local taskID=ARGV[i]"
cards := []Card{{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}}
if stage == "producer_done" {
match = "'producerCompletedAt'"
cards = nil
}
real, held, release := leaseRedisProxy(t, backend, match, false)
defer release()
workContexts := make(chan context.Context, 1)
scanner := &lockTestScanner{cardScanner: cardScanner{cards: cards}}
scanner.snapshot = func(ctx context.Context) (int64, error) { workContexts <- ctx; return int64(len(cards)), nil }
store, err := queue.New(real, "imei:")
if err != nil {
t.Fatal(err)
}
config := Config{RunLockTTL: time.Second}
if mode == "sentinel" {
real, config.LeaseRedisConfig = sentinelLeaseRedis(t, real)
store, err = queue.New(real, "imei:")
if err != nil {
t.Fatal(err)
}
}
producer, err := New(scanner, store, config)
if err != nil {
t.Fatal(err)
}
result := startLockTestProducer(t, producer)
select {
case <-held:
case <-time.After(2 * time.Second):
t.Fatal("proxy did not hold old-owner EVAL")
}
workCtx := <-workContexts
observer, err := queue.New(backend, "imei:")
if err != nil {
t.Fatal(err)
}
ok, err := observer.AcquireLockOrTakeover(context.Background(), producer.config.RunLockKey, "new-owner", "lock-test", time.Minute)
if err != nil || !ok {
t.Fatalf("takeover: %v, %v", ok, err)
}
select {
case <-workCtx.Done():
case <-time.After(2 * time.Second):
t.Fatal("real renewal did not detect takeover")
}
if !errors.Is(context.Cause(workCtx), ErrAlreadyRunning) {
t.Fatalf("wrong loss cause: %v", context.Cause(workCtx))
}
release()
select {
case err := <-result:
if !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("wrong result: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("producer did not finish")
}
meta, err := observer.GetRunMeta(context.Background(), "lock-test")
if err != nil || meta.ActiveCount != 0 || meta.ProducerDone {
t.Fatalf("stale owner changed run state: %+v, %v", meta, err)
}
channels, err := observer.RunChannels(context.Background(), "lock-test")
if err != nil || len(channels) != 0 {
t.Fatalf("stale owner indexed channel: %v, %v", channels, err)
}
ok, err = observer.RenewLock(context.Background(), producer.config.RunLockKey, "new-owner", time.Minute)
if err != nil || !ok {
t.Fatalf("old cleanup removed new lease: %v, %v", ok, err)
}
added, err := observer.EnqueueRunBatch(context.Background(), "lock-test", "ch", []queue.Task{{TaskID: "next", RunID: "lock-test", ChanKey: "ch", CardID: 2}}, time.Now())
if err != nil || added != 1 {
t.Fatalf("new work rejected: added=%d, %v", added, err)
}
})
}
}
func leaseRedisProxy(t *testing.T, upstream *gredis.Redis, match string, holdReply bool) (*gredis.Redis, <-chan struct{}, func()) {
t.Helper()
raw := upstream.GetAdapter().Client().(*goredis.Client)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
held, gate := make(chan struct{}), make(chan struct{})
var heldOnce, releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(gate) }) }
var connections sync.Map
go func() {
for {
down, err := listener.Accept()
if err != nil {
return
}
connections.Store(down, struct{}{})
go func() {
defer down.Close()
defer connections.Delete(down)
up, err := net.Dial("tcp", raw.Options().Addr)
if err != nil {
return
}
defer up.Close()
dr, ur := bufio.NewReader(down), bufio.NewReader(up)
for {
request, err := readLeaseRESP(dr)
if err != nil {
return
}
holding := strings.Contains(string(request), match)
if holding && !holdReply {
heldOnce.Do(func() { close(held) })
<-gate
}
if _, err := up.Write(request); err != nil {
return
}
response, err := readLeaseRESP(ur)
if err != nil {
return
}
if holding && holdReply {
heldOnce.Do(func() { close(held) })
<-gate
}
if _, err := down.Write(response); err != nil {
return
}
}
}()
}
}()
t.Cleanup(func() {
release()
_ = listener.Close()
connections.Range(func(key, _ any) bool { _ = key.(net.Conn).Close(); return true })
})
client, err := gredis.New(&gredis.Config{Address: listener.Addr().String(), DialTimeout: 100 * time.Millisecond, Protocol: 2})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = client.Close(context.Background()) })
if _, err := client.Do(context.Background(), "PING"); err != nil {
t.Fatal(err)
}
opts := client.GetAdapter().Client().(*goredis.Client).Options()
t.Logf("real driver options: readTimeout=%s writeTimeout=%s contextTimeoutEnabled=%v", opts.ReadTimeout, opts.WriteTimeout, opts.ContextTimeoutEnabled)
return client, held, release
}
// A real disposable Sentinel discovers the proxy as its master; neither client
// configuration nor server state comes from the application's Redis settings.
func sentinelLeaseRedis(t *testing.T, master *gredis.Redis) (*gredis.Redis, *gredis.Config) {
t.Helper()
binary := os.Getenv("REDIS_SERVER_BINARY")
if binary == "" {
var err error
binary, err = exec.LookPath("redis-server")
if err != nil {
t.Skip("Sentinel tests require redis-server")
}
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
_, port, _ := net.SplitHostPort(address)
_ = listener.Close()
masterAddress := master.GetAdapter().Client().(*goredis.Client).Options().Addr
host, masterPort, err := net.SplitHostPort(masterAddress)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
path := filepath.Join(dir, "sentinel.conf")
contents := "bind 127.0.0.1\nport " + port + "\nsentinel monitor lease-test " + host + " " + masterPort + " 1\nsentinel down-after-milliseconds lease-test 60000\n"
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
command := exec.Command(binary, filepath.Base(path), "--sentinel")
command.Dir = dir
logPath := filepath.Join(dir, "sentinel.log")
logFile, err := os.Create(logPath)
if err != nil {
t.Fatal(err)
}
defer logFile.Close()
command.Stdout, command.Stderr = logFile, logFile
if err := command.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = command.Process.Kill(); _ = command.Wait() })
probe, err := gredis.New(&gredis.Config{Address: address, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond, DialTimeout: 100 * time.Millisecond, Protocol: 2})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = probe.Close(context.Background()) })
for deadline := time.Now().Add(5 * time.Second); ; {
if _, err = probe.Do(context.Background(), "SENTINEL", "get-master-addr-by-name", "lease-test"); err == nil {
break
}
if time.Now().After(deadline) {
log, _ := os.ReadFile(logPath)
t.Fatalf("isolated Sentinel did not start: %v; %s", err, log)
}
time.Sleep(20 * time.Millisecond)
}
config := &gredis.Config{Address: address, MasterName: "lease-test", DialTimeout: 100 * time.Millisecond, Protocol: 2}
client, err := gredis.New(config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = client.Close(context.Background()) })
return client, config
}
func TestLeaseClientBoundsSentinelDiscoveryIO(t *testing.T) {
master := testutil.Redis(t)
_, config := sentinelLeaseRedis(t, master)
sentinel, err := gredis.New(&gredis.Config{Address: config.Address, ReadTimeout: time.Second, WriteTimeout: time.Second, Protocol: 2})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sentinel.Close(context.Background()) })
proxy, held, release := leaseRedisProxy(t, sentinel, "get-master-addr-by-name", true)
defer release()
config.Address = proxy.GetAdapter().Client().(*goredis.Client).Options().Addr
shared, err := gredis.New(config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = shared.Close(context.Background()) })
factory, err := newLeaseClientFactory(shared, config, 100*time.Millisecond)
if err != nil {
t.Fatal(err)
}
client, closeClient, err := factory()
if err != nil {
t.Fatal(err)
}
defer closeClient()
result := make(chan error, 1)
go func() { _, err := client.Do(context.Background(), "PING"); result <- err }()
select {
case <-held:
case <-time.After(2 * time.Second):
t.Fatal("Sentinel discovery request was not held")
}
select {
case err := <-result:
if err == nil {
t.Fatal("stalled Sentinel discovery unexpectedly succeeded")
}
case <-time.After(600 * time.Millisecond):
release()
<-result
t.Fatal("Sentinel discovery has no socket I/O upper bound")
}
}
func TestLeaseClientRejectsClusterAndUnknownAdapter(t *testing.T) {
cluster, err := gredis.New(&gredis.Config{Address: "127.0.0.1:1", Cluster: true})
if err != nil {
t.Fatal(err)
}
defer cluster.Close(context.Background())
store, err := queue.New(cluster, "imei:")
if err != nil {
t.Fatal(err)
}
if _, err := New(cardScanner{}, store, Config{}); err == nil || !strings.Contains(err.Error(), "Cluster") {
t.Fatalf("Cluster must be rejected at initialization before any write: %v", err)
}
if _, err := newLeaseClientFactory(&leaseRedis{}, nil, time.Second); err == nil {
t.Fatal("unknown adapter must not fall back to unbounded context-only I/O")
}
}
func TestProduceBoundsReleaseIO(t *testing.T) {
backend := testutil.Redis(t)
real, held, release := leaseRedisProxy(t, backend, "redis.call('DEL', KEYS[1])", true)
defer release()
store, err := queue.New(real, "imei:")
if err != nil {
t.Fatal(err)
}
producer, err := New(cardScanner{}, store, Config{RunLockTTL: time.Second})
if err != nil {
t.Fatal(err)
}
result := startLockTestProducer(t, producer)
select {
case <-held:
case <-time.After(2 * time.Second):
t.Fatal("release response was not held")
}
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(600 * time.Millisecond):
release()
<-result
t.Fatal("release socket I/O blocked cleanup")
}
if producer.running.Load() != 0 {
t.Fatal("cleanup did not reset running")
}
}
func TestLeaseClientBoundsSocketWriteIO(t *testing.T) {
backend := testutil.Redis(t)
upstream := backend.GetAdapter().Client().(*goredis.Client).Options().Addr
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
var stopReading atomic.Bool
held, gate := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(gate) }) }
defer release()
t.Cleanup(func() { _ = listener.Close() })
go func() {
down, err := listener.Accept()
if err != nil {
return
}
defer down.Close()
up, err := net.Dial("tcp", upstream)
if err != nil {
return
}
defer up.Close()
dr, ur := bufio.NewReader(down), bufio.NewReader(up)
for {
// Read only the array header once armed: the large EVAL payload
// cannot enter the upstream or fit in TCP buffers, so Write blocks.
header, err := dr.ReadBytes('\n')
if err != nil {
return
}
if stopReading.Load() {
close(held)
<-gate
return
}
n, err := strconv.Atoi(strings.TrimSpace(string(header[1:])))
if err != nil {
return
}
for i := 0; i < n; i++ {
part, err := readLeaseRESP(dr)
if err != nil {
return
}
header = append(header, part...)
}
if _, err := up.Write(header); err != nil {
return
}
response, err := readLeaseRESP(ur)
if err != nil {
return
}
if _, err := down.Write(response); err != nil {
return
}
}
}()
shared, err := gredis.New(&gredis.Config{Address: listener.Addr().String(), Protocol: 2})
if err != nil {
t.Fatal(err)
}
defer shared.Close(context.Background())
factory, err := newLeaseClientFactory(shared, nil, 100*time.Millisecond)
if err != nil {
t.Fatal(err)
}
client, closeClient, err := factory()
if err != nil {
t.Fatal(err)
}
defer closeClient()
if _, err := client.Do(context.Background(), "PING"); err != nil {
t.Fatal(err)
}
opts := client.(*leaseRedis).client.Options()
if opts.ReadTimeout != 100*time.Millisecond || opts.WriteTimeout != 100*time.Millisecond || !opts.ContextTimeoutEnabled || opts.MaxRetries != 0 || opts.DialerRetries != 1 {
t.Fatalf("lease client has unsafe timeout/retry options")
}
stopReading.Store(true)
result := make(chan error, 1)
go func() {
_, err := client.Eval(context.Background(), "return 1 --"+strings.Repeat("x", 32<<20), 0, nil, nil)
result <- err
}()
select {
case <-held:
case <-time.After(2 * time.Second):
t.Fatal("write proxy did not stop reading")
}
select {
case err := <-result:
var timeout net.Error
if !errors.As(err, &timeout) || !timeout.Timeout() || !strings.Contains(err.Error(), "write") {
t.Fatalf("expected real socket write timeout, got %v", err)
}
case <-time.After(600 * time.Millisecond):
release()
<-result
t.Fatal("socket write has no upper bound")
}
}
@@ -0,0 +1,405 @@
package imei
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gogf/gf/v2/container/gvar"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
type lockTestRedis struct {
queue.RedisClient
do func(context.Context, string, []any) (*gvar.Var, error)
eval func(context.Context, string, int64, []string, []any) (*gvar.Var, error)
}
func (r *lockTestRedis) newBoundedLeaseClient(budget time.Duration) (queue.RedisClient, func() error, error) {
factory, err := newLeaseClientFactory(r.RedisClient, nil, budget)
if err != nil {
return nil, nil, err
}
client, closeClient, err := factory()
if err != nil {
return nil, nil, err
}
return &lockTestRedis{RedisClient: client,
do: func(ctx context.Context, command string, args []any) (*gvar.Var, error) {
if r.do != nil {
return r.do(ctx, command, args)
}
return client.Do(ctx, command, args...)
},
eval: func(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
if r.eval != nil {
return r.eval(ctx, script, numKeys, keys, args)
}
return client.Eval(ctx, script, numKeys, keys, args)
},
}, closeClient, nil
}
func (r *lockTestRedis) Do(ctx context.Context, command string, args ...any) (*gvar.Var, error) {
if r.do != nil {
return r.do(ctx, command, args)
}
return r.RedisClient.Do(ctx, command, args...)
}
func (r *lockTestRedis) Eval(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
if r.eval != nil {
return r.eval(ctx, script, numKeys, keys, args)
}
return r.RedisClient.Eval(ctx, script, numKeys, keys, args)
}
type lockTestScanner struct {
cardScanner
scanCalls atomic.Int32
snapshot func(context.Context) (int64, error)
scan func(context.Context, int64, int64, int) ([]Card, error)
}
func (s *lockTestScanner) SnapshotMaxID(ctx context.Context) (int64, error) {
if s.snapshot != nil {
return s.snapshot(ctx)
}
return s.cardScanner.SnapshotMaxID(ctx)
}
func (s *lockTestScanner) Scan(ctx context.Context, last, max int64, limit int) ([]Card, error) {
s.scanCalls.Add(1)
if s.scan != nil {
return s.scan(ctx, last, max, limit)
}
return s.cardScanner.Scan(ctx, last, max, limit)
}
func startLockTestProducer(t *testing.T, producer *Producer) <-chan error {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
done := make(chan struct{})
go func() {
defer close(done)
result <- producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "lock-test"})
}()
t.Cleanup(func() {
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Error("producer goroutine did not stop after cleanup cancellation")
}
})
return result
}
func TestProduceStopsOnLockLossWhileWaitingForCapacity(t *testing.T) {
backendErr := errors.New("renewal backend unavailable")
for _, test := range []struct {
name string
err error
}{
{name: "taken_over", err: ErrAlreadyRunning},
{name: "redis_error", err: backendErr},
} {
t.Run(test.name, func(t *testing.T) {
var (
client = &lockTestRedis{RedisClient: testutil.Redis(t)}
waiting = make(chan struct{})
failRenew = make(chan struct{})
waitingOnce sync.Once
capacityRead atomic.Bool
scanner = &lockTestScanner{cardScanner: cardScanner{cards: []Card{
{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"},
{ID: 2, ICCID: "b", ChanKey: "ch", TempKey: "sim_yd_v5"},
{ID: 3, ICCID: "c", ChanKey: "ch", TempKey: "sim_yd_v5"},
}}}
)
store, err := queue.New(client, "imei:")
if err != nil {
t.Fatal(err)
}
producer, err := New(scanner, store, Config{
PageSize: 2, GlobalHighWaterMark: 1,
RunLockTTL: 300 * time.Millisecond, PollInterval: time.Hour,
})
if err != nil {
t.Fatal(err)
}
client.do = func(ctx context.Context, command string, args []any) (*gvar.Var, error) {
result, err := client.RedisClient.Do(ctx, command, args...)
if err == nil && command == "SMEMBERS" && args[0] == store.ActiveRunsKey() {
capacityRead.Store(true)
}
if err == nil && command == "HGETALL" && capacityRead.Load() && result.MapStrStr()["activeCount"] == "2" {
waitingOnce.Do(func() { close(waiting) })
}
return result, err
}
client.eval = func(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
if strings.Contains(script, "redis.call('PEXPIRE'") {
select {
case <-failRenew:
case <-ctx.Done():
return nil, ctx.Err()
}
if test.err == backendErr {
return nil, backendErr
}
}
return client.RedisClient.Eval(ctx, script, numKeys, keys, args)
}
result := startLockTestProducer(t, producer)
select {
case <-waiting:
case <-time.After(2 * time.Second):
t.Fatal("producer did not reach backpressure")
}
// The long poll cannot finish naturally; renewal failure must wake it.
select {
case err := <-result:
t.Fatalf("producer exited before lock loss: %v", err)
case <-time.After(20 * time.Millisecond):
}
if test.err == ErrAlreadyRunning {
ok, err := store.AcquireLockOrTakeover(context.Background(), producer.config.RunLockKey, "new-owner", "lock-test", time.Minute)
if err != nil || !ok {
t.Fatalf("take over lock: acquired=%v err=%v", ok, err)
}
}
close(failRenew)
select {
case err := <-result:
if !errors.Is(err, test.err) {
t.Fatalf("producer error = %v, want renewal cause %v", err, test.err)
}
case <-time.After(2 * time.Second):
t.Fatal("lock loss did not interrupt backpressure before the next capacity poll")
}
if calls := scanner.scanCalls.Load(); calls != 1 {
t.Fatalf("scan calls = %d, want only the first page", calls)
}
meta, err := store.GetRunMeta(context.Background(), "lock-test")
if err != nil || meta.ProducerDone || meta.ActiveCount != 2 {
t.Fatalf("run must remain incomplete without additional tasks: meta=%+v err=%v", meta, err)
}
if producer.running.Load() != 0 {
t.Fatal("producer running flag was not reset")
}
if test.err == ErrAlreadyRunning {
ok, err := store.RenewLock(context.Background(), producer.config.RunLockKey, "new-owner", time.Minute)
if err != nil || !ok {
t.Fatalf("old producer released the new owner's lock: renewed=%v err=%v", ok, err)
}
}
})
}
}
func TestProduceCancelsLockedOperationsWithRenewalCause(t *testing.T) {
for _, stage := range []string{"run_init", "snapshot", "scan", "enqueue", "producer_done"} {
t.Run(stage, func(t *testing.T) {
var (
client = &lockTestRedis{RedisClient: testutil.Redis(t)}
entered = make(chan context.Context, 1)
failRenew = make(chan struct{})
cause = errors.New("renewal failed during " + stage)
scanner = &lockTestScanner{cardScanner: cardScanner{cards: []Card{
{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"},
}}}
)
block := func(ctx context.Context) error {
entered <- ctx
<-ctx.Done()
return ctx.Err()
}
if stage == "snapshot" {
scanner.snapshot = func(ctx context.Context) (int64, error) { return 0, block(ctx) }
}
if stage == "scan" {
scanner.scan = func(ctx context.Context, _, _ int64, _ int) ([]Card, error) { return nil, block(ctx) }
}
store, err := queue.New(client, "imei:")
if err != nil {
t.Fatal(err)
}
producer, err := New(scanner, store, Config{RunLockTTL: 300 * time.Millisecond})
if err != nil {
t.Fatal(err)
}
client.eval = func(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
if strings.Contains(script, "redis.call('PEXPIRE'") {
select {
case <-failRenew:
return nil, cause
case <-ctx.Done():
return nil, ctx.Err()
}
}
if stage == "run_init" && strings.Contains(script, "'createdAt', ARGV[12]") ||
stage == "enqueue" && strings.Contains(script, "local taskID=ARGV[i]") ||
stage == "producer_done" && strings.Contains(script, "'producerCompletedAt'") {
return nil, block(ctx)
}
return client.RedisClient.Eval(ctx, script, numKeys, keys, args)
}
result := startLockTestProducer(t, producer)
var workCtx context.Context
select {
case workCtx = <-entered:
case <-time.After(2 * time.Second):
t.Fatal("producer did not enter " + stage)
}
close(failRenew)
select {
case err := <-result:
if !errors.Is(err, cause) || !errors.Is(context.Cause(workCtx), cause) {
t.Fatalf("renewal cause lost: result=%v context cause=%v", err, context.Cause(workCtx))
}
case <-time.After(2 * time.Second):
t.Fatal("lock loss did not interrupt " + stage)
}
if stage == "run_init" {
if scanner.scanCalls.Load() != 0 {
t.Fatal("producer scanned after run initialization failed")
}
return
}
meta, err := store.GetRunMeta(context.Background(), "lock-test")
wantActive := int64(0)
if stage == "producer_done" {
wantActive = 1
}
if err != nil || meta.ProducerDone || meta.ActiveCount != wantActive {
t.Fatalf("unexpected work after lock loss: meta=%+v err=%v", meta, err)
}
})
}
}
func TestProduceJoinsRenewalBeforeReleasingLock(t *testing.T) {
scanErr := errors.New("snapshot failed")
for _, test := range []struct {
name string
err error
}{
{name: "success"},
{name: "scanner_error", err: scanErr},
} {
t.Run(test.name, func(t *testing.T) {
var (
client = &lockTestRedis{RedisClient: testutil.Redis(t)}
renewEntered = make(chan struct{})
renewCanceled = make(chan struct{})
allowRenewReturn = make(chan struct{})
resumeSnapshot = make(chan struct{})
released = make(chan struct{}, 1)
allowReturnOnce sync.Once
scanner = &lockTestScanner{}
)
scanner.snapshot = func(ctx context.Context) (int64, error) {
select {
case <-resumeSnapshot:
return 0, test.err
case <-ctx.Done():
return 0, ctx.Err()
}
}
store, err := queue.New(client, "imei:")
if err != nil {
t.Fatal(err)
}
producer, err := New(scanner, store, Config{RunLockTTL: 300 * time.Millisecond})
if err != nil {
t.Fatal(err)
}
client.eval = func(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
if strings.Contains(script, "redis.call('PEXPIRE'") {
close(renewEntered)
<-ctx.Done()
close(renewCanceled)
<-allowRenewReturn
return nil, ctx.Err()
}
if len(keys) == 1 && keys[0] == producer.config.RunLockKey && len(args) == 1 {
released <- struct{}{}
}
return client.RedisClient.Eval(ctx, script, numKeys, keys, args)
}
result := startLockTestProducer(t, producer)
t.Cleanup(func() { allowReturnOnce.Do(func() { close(allowRenewReturn) }) })
select {
case <-renewEntered:
case <-time.After(2 * time.Second):
t.Fatal("renewal did not start")
}
close(resumeSnapshot)
select {
case <-renewCanceled:
case <-time.After(2 * time.Second):
t.Fatal("producer did not cancel renewal on exit")
}
// Keep the in-flight renewal alive to verify cleanup actually joins it.
select {
case err := <-result:
t.Fatalf("producer returned before renewal exited: %v", err)
case <-released:
t.Fatal("producer released the lock before renewal exited")
case <-time.After(20 * time.Millisecond):
}
allowReturnOnce.Do(func() { close(allowRenewReturn) })
select {
case err := <-result:
if !errors.Is(err, test.err) {
t.Fatalf("cleanup changed the production error: got %v, want %v", err, test.err)
}
case <-time.After(2 * time.Second):
t.Fatal("producer did not finish after renewal exited")
}
select {
case <-released:
default:
t.Fatal("producer did not release the lock after joining renewal")
}
})
}
}
func TestProducePreservesCallerCancellationCause(t *testing.T) {
producer, store := newProducer(t, nil)
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
cause := errors.New("caller stopped the run")
producer.scanner = &lockTestScanner{snapshot: func(ctx context.Context) (int64, error) {
cancel(cause)
<-ctx.Done()
return 0, ctx.Err()
}}
if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "caller-canceled"}); !errors.Is(err, cause) {
t.Fatalf("caller cancellation cause lost: %v", err)
}
meta, err := store.GetRunMeta(context.Background(), "caller-canceled")
if err != nil || meta.ProducerDone || producer.running.Load() != 0 {
t.Fatalf("canceled producer did not clean up: meta=%+v err=%v running=%d", meta, err, producer.running.Load())
}
}
func TestWaitForCapacityHonorsCancellationWithoutWatermark(t *testing.T) {
producer := &Producer{}
ctx, cancel := context.WithCancelCause(context.Background())
cause := errors.New("lock lost")
cancel(cause)
if err := producer.waitForCapacity(ctx); !errors.Is(err, cause) {
t.Fatalf("disabled watermark ignored cancellation: %v", err)
}
}
@@ -0,0 +1,498 @@
package imei
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/gogf/gf/v2/database/gredis"
"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/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
)
// FullSyncOperation is recorded on the run and used by SyncKindForOperation to
// classify the run for the admin monitor.
const FullSyncOperation = "imei_full_sync"
// scopeSampleLimit bounds how many channel names are copied into run metadata.
const scopeSampleLimit = 10
// productionStats mirrors the status producer so every full-sync path reports a
// single comparable summary line.
type productionStats struct {
pages int64
scanned int64
enqueued int64
skippedEmptyChan int64
skippedNoTemplate int64
skippedSyncDisabled int64
skippedOutOfScope int64
skippedNoImei int64
scopeChannels int
channels map[string]struct{}
}
func newProductionStats(scopeChannels int) productionStats {
return productionStats{channels: make(map[string]struct{}), scopeChannels: scopeChannels}
}
func (s *productionStats) log(ctx context.Context, status, runID, stage, reason string, startedAt time.Time, maxID int64, err error) {
message := fmt.Sprintf("event=imei_queue_producer status=%s runId=%s snapshotMaxId=%d pages=%d scanned=%d enqueued=%d skippedEmptyChan=%d skippedNoTemplate=%d skippedSyncDisabled=%d skippedOutOfScope=%d skippedNoImei=%d channelCount=%d scopeChannels=%d durationMs=%d", status, runID, maxID, s.pages, s.scanned, s.enqueued, s.skippedEmptyChan, s.skippedNoTemplate, s.skippedSyncDisabled, s.skippedOutOfScope, s.skippedNoImei, len(s.channels), s.scopeChannels, time.Since(startedAt).Milliseconds())
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())
}
switch status {
case "failed":
g.Log().Error(ctx, message)
case "skipped":
g.Log().Warning(ctx, message)
default:
g.Log().Info(ctx, message)
}
}
type Config struct {
PageSize int
RunLockKey string
RunLockTTL time.Duration
PollInterval time.Duration
GlobalHighWaterMark int64
// LeaseRedisConfig is required only for Sentinel discovery. Standalone
// settings come from the actual adapter; Cluster is rejected in New.
LeaseRedisConfig *gredis.Config
}
func (c Config) withDefaults() Config {
if c.PageSize <= 0 {
c.PageSize = 1000
}
if c.RunLockKey == "" {
c.RunLockKey = "imei:producer:active"
}
if c.RunLockTTL <= 0 {
c.RunLockTTL = 5 * time.Minute
}
if c.PollInterval <= 0 {
c.PollInterval = time.Second
}
return c
}
type Card struct {
ID int64
ICCID string
MSISDN string
IMSI string
ChanKey string
TempKey string
Operator int
SyncEnabled bool
Imei string
}
type Scanner interface {
SnapshotMaxID(context.Context) (int64, error)
Scan(context.Context, int64, int64, int) ([]Card, error)
}
type channelMetadata struct {
ChanKey string `orm:"chan_key"`
TempKey string `orm:"temp_key"`
Operator int `orm:"operator"`
SyncEnabled bool `orm:"sync_enabled"`
}
// DatabaseScanner uses keyset pagination so a large IMEI run does not hold the
// entire card table in memory and does not degrade through OFFSET scans.
type DatabaseScanner struct {
metadata map[string]channelMetadata
}
func (s *DatabaseScanner) SnapshotMaxID(ctx context.Context) (int64, error) {
var card entity.TSimCardInfo
columns := dao.TSimCardInfo.Columns()
if err := dao.TSimCardInfo.Ctx(ctx).
Fields(columns.Id).
OrderDesc(columns.Id).
Limit(1).
Scan(&card); err != nil {
return 0, fmt.Errorf("read imei snapshot max id: %w", err)
}
return card.Id, nil
}
func (s *DatabaseScanner) Scan(ctx context.Context, lastID, maxID int64, pageSize int) ([]Card, error) {
if maxID <= lastID {
return nil, nil
}
if s.metadata == nil {
var rows []channelMetadata
if err := g.DB().Model("t_sim_channels").Ctx(ctx).Fields("chan_key,temp_key,sync_enabled,operator").Scan(&rows); err != nil {
return nil, fmt.Errorf("scan imei channel metadata: %w", err)
}
s.metadata = make(map[string]channelMetadata, len(rows))
for _, row := range rows {
s.metadata[row.ChanKey] = row
}
}
columns := dao.TSimCardInfo.Columns()
var rows []entity.TSimCardInfo
if err := dao.TSimCardInfo.Ctx(ctx).
Fields(columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi, columns.ChanKey, columns.Imei).
WhereGT(columns.Id, lastID).
WhereLTE(columns.Id, maxID).
OrderAsc(columns.Id).
Limit(pageSize).
Scan(&rows); err != nil {
return nil, fmt.Errorf("scan imei cards: %w", err)
}
cards := make([]Card, 0, len(rows))
for _, row := range rows {
if row.Id <= 0 || row.Iccid == "" || row.ChanKey == "" {
// Keep the page cursor moving even when a row cannot become a
// runnable task. The producer advances by the highest returned ID.
if row.Id > 0 {
cards = append(cards, Card{ID: row.Id})
}
continue
}
metadata := s.metadata[row.ChanKey]
cards = append(cards, Card{ID: row.Id, ICCID: row.Iccid, MSISDN: row.Msisdn, IMSI: row.Imsi, ChanKey: row.ChanKey, TempKey: metadata.TempKey, Operator: metadata.Operator, SyncEnabled: metadata.SyncEnabled, Imei: row.Imei})
}
return cards, nil
}
type Producer struct {
scanner Scanner
store *queue.Store
config Config
running atomic.Int32
owner string
leaseFactory leaseClientFactory
}
var ErrAlreadyRunning = fmt.Errorf("imei 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.
var ErrRunFinished = fmt.Errorf("imei 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 New(scanner Scanner, store *queue.Store, config Config) (*Producer, error) {
if scanner == nil || store == nil {
return nil, fmt.Errorf("imei producer dependencies are incomplete")
}
config = config.withDefaults()
factory, err := newLeaseClientFactory(store.RedisClient(), config.LeaseRedisConfig, leaseIOBudget(config.RunLockTTL))
if err != nil {
return nil, err
}
return &Producer{scanner: scanner, store: store, config: config, owner: newOwner(), leaseFactory: factory}, nil
}
func leaseIOBudget(ttl time.Duration) time.Duration {
return min(time.Second, ttl/6)
}
func (p *Producer) Produce(ctx context.Context, message model.ImeiQueueStartMessage) (err error) {
startedAt := time.Now()
if message.RunID == "" {
return fmt.Errorf("imei sync runId is empty")
}
operation := message.Operation
if operation == "" {
operation = FullSyncOperation
}
scope, err := model.NormalizeScope(message.Scope)
if err != nil {
failedStats := newProductionStats(0)
failedStats.log(ctx, "failed", message.RunID, "validate", "invalid_scope", startedAt, 0, err)
return err
}
stats := newProductionStats(scope.ChannelCount())
if !p.running.CompareAndSwap(0, 1) {
stats.log(ctx, "skipped", message.RunID, "precheck", "already_running", startedAt, 0, nil)
return ErrAlreadyRunning
}
defer p.running.Store(0)
leaseClient, closeLeaseClient, err := p.leaseFactory()
if err != nil {
return err
}
defer closeLeaseClient()
leaseStore, err := queue.New(leaseClient, p.store.Prefix())
if err != nil {
return err
}
// A redelivered start message must not resurrect a run that already
// finished; that would create a second producer pass over the same RunID.
if finished, checkErr := p.runAlreadyFinished(ctx, message.RunID); checkErr != nil {
stats.log(ctx, "failed", message.RunID, "run_state", "", startedAt, 0, checkErr)
return checkErr
} else if finished {
stats.log(ctx, "skipped", message.RunID, "run_state", "run_finished", startedAt, 0, nil)
return ErrRunFinished
}
// Tokens are per lease, not per Producer: a delayed command from an earlier
// attempt must never match a later lease on this same Producer instance.
p.owner = newOwner()
acquireCtx, cancelAcquire := context.WithTimeout(ctx, leaseIOBudget(p.config.RunLockTTL))
acquired, err := leaseStore.AcquireLockOrTakeover(acquireCtx, p.config.RunLockKey, p.owner, message.RunID, p.config.RunLockTTL)
cancelAcquire()
if err != nil {
stats.log(ctx, "failed", message.RunID, "lock", "", startedAt, 0, err)
return err
}
if !acquired {
stats.log(ctx, "skipped", message.RunID, "lock", "already_running", startedAt, 0, nil)
return ErrAlreadyRunning
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), leaseIOBudget(p.config.RunLockTTL))
defer cancel()
_, _ = leaseStore.ReleaseLock(releaseCtx, p.config.RunLockKey, p.owner)
}()
workStore, err := p.store.WithIMEIProducerLease(p.config.RunLockKey, p.owner, message.RunID)
if err != nil {
return err
}
lockCtx, cancelLock := context.WithCancelCause(ctx)
// Stop renewal separately so normal cleanup does not become a lock-loss cause.
renewCtx, stopRenew := context.WithCancel(lockCtx)
renewDone := make(chan struct{})
var (
maxID int64
stage = "run_init"
)
go func() {
defer close(renewDone)
p.renewLock(renewCtx, cancelLock, leaseStore)
}()
defer func() {
stopRenew()
<-renewDone
// Join before reading the cause and releasing the lease: a failed renewal
// must not be masked by a scanner/Redis context-cancellation error.
if cause := context.Cause(lockCtx); cause != nil {
err = cause
}
if errors.Is(err, queue.ErrProducerLeaseLost) {
err = ErrAlreadyRunning
}
cancelLock(nil)
if err != nil {
stats.log(ctx, "failed", message.RunID, stage, "", startedAt, maxID, err)
return
}
stats.log(ctx, "completed", message.RunID, "", "", startedAt, maxID, nil)
}()
if err = workStore.CreateRun(lockCtx, queue.RunMeta{
RunID: message.RunID,
Operation: operation,
Status: "producing",
CurrentRound: 1,
ScopeChannels: scope.ChannelCount(),
ScopeDigest: scopeDigest(scope),
ScopeChannelsSample: strings.Join(scope.ChannelSample(scopeSampleLimit), ","),
}); err != nil {
return err
}
stage = "snapshot"
if err = lockCtx.Err(); err != nil {
return err
}
maxID, err = p.scanner.SnapshotMaxID(lockCtx)
if err != nil {
return err
}
g.Log().Infof(ctx, "event=imei_queue_producer status=started runId=%s snapshotMaxId=%d scopeChannels=%d", message.RunID, maxID, scope.ChannelCount())
lastID := int64(0)
for lastID < maxID {
if err = lockCtx.Err(); err != nil {
return err
}
stage = "scan"
cards, scanErr := p.scanner.Scan(lockCtx, lastID, maxID, p.config.PageSize)
if scanErr != nil {
return scanErr
}
if len(cards) == 0 {
break
}
stats.pages++
stats.scanned += int64(len(cards))
groups := make(map[string][]queue.Task)
for _, card := range cards {
if err = lockCtx.Err(); err != nil {
return err
}
if card.ID > lastID {
lastID = card.ID
}
if card.ChanKey == "" {
stats.skippedEmptyChan++
continue
}
// Scope is applied before the template and sync_enabled checks so a
// partial run only reports on the channels it was asked for.
if !scope.Allows(card.ChanKey) {
stats.skippedOutOfScope++
continue
}
if card.TempKey == "" {
stats.skippedNoTemplate++
continue
}
if card.SyncEnabled {
// sync_enabled=1(否/不参与) → skip
stats.skippedSyncDisabled++
continue
}
// skippedNoImei counts cards that already have a value. They are
// still enqueued (this is a full re-poll, so a device swap is
// detected), but the count makes the "how much of this pass is
// actually filling gaps" question answerable from one log line.
if strings.TrimSpace(card.Imei) != "" {
stats.skippedNoImei++
}
stats.channels[card.ChanKey] = struct{}{}
task := queue.Task{TaskID: queue.StableTaskID(message.RunID, operation, card.ID), RunID: message.RunID, Operation: operation, ChanKey: card.ChanKey, TempKey: card.TempKey, Operator: card.Operator, CardID: card.ID, ICCID: card.ICCID, MSISDN: card.MSISDN, IMSI: card.IMSI}
groups[card.ChanKey] = append(groups[card.ChanKey], task)
}
for chanKey, tasks := range groups {
stage = "enqueue"
if err = lockCtx.Err(); err != nil {
return err
}
added, enqueueErr := workStore.EnqueueRunBatch(lockCtx, message.RunID, chanKey, tasks, time.Now())
if enqueueErr != nil {
return fmt.Errorf("enqueue imei channel %s: %w", chanKey, enqueueErr)
}
stats.enqueued += int64(added)
}
stage = "capacity"
if err = p.waitForCapacity(lockCtx); err != nil {
return err
}
}
stage = "producer_done"
if err = lockCtx.Err(); err != nil {
return err
}
return workStore.MarkProducerDone(lockCtx, message.RunID)
}
// scopeDigest is a stable short fingerprint of the selected channels.
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])
}
func (p *Producer) renewLock(ctx context.Context, cancel context.CancelCauseFunc, leaseStore *queue.Store) {
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:
if ctx.Err() != nil {
return
}
renewCtx, stop := context.WithTimeout(ctx, leaseIOBudget(p.config.RunLockTTL))
ok, err := leaseStore.RenewLock(renewCtx, p.config.RunLockKey, p.owner, p.config.RunLockTTL)
stop()
if ctx.Err() != nil {
return
}
if err == nil && !ok {
err = ErrAlreadyRunning
}
if err != nil {
cancel(err)
return
}
}
}
}
func (p *Producer) waitForCapacity(ctx context.Context) error {
if ctx.Err() != nil {
return context.Cause(ctx)
}
if p.config.GlobalHighWaterMark <= 0 {
return nil
}
for {
if ctx.Err() != nil {
return context.Cause(ctx)
}
runs, err := p.store.ActiveRuns(ctx)
if err != nil {
return err
}
var total int64
for _, runID := range runs {
meta, sizeErr := p.store.GetRunMeta(ctx, runID)
if sizeErr != nil {
return sizeErr
}
// ActiveCount includes leased tasks; do not count InflightCount twice.
total += meta.ActiveCount + meta.NextRoundCount
}
if total <= p.config.GlobalHighWaterMark {
return nil
}
select {
case <-ctx.Done():
return context.Cause(ctx)
case <-time.After(p.config.PollInterval):
}
}
}
func newOwner() string {
var b [12]byte
if _, err := rand.Read(b[:]); err == nil {
return hex.EncodeToString(b[:])
}
return fmt.Sprintf("%d", time.Now().UnixNano())
}
@@ -0,0 +1,262 @@
package imei
import (
"context"
"errors"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
type cardScanner struct{ cards []Card }
func (s cardScanner) SnapshotMaxID(context.Context) (int64, error) { return int64(len(s.cards)), nil }
func (s cardScanner) Scan(_ context.Context, last, max int64, limit int) ([]Card, error) {
end := last + int64(limit)
if end > max {
end = max
}
return s.cards[last:end], nil
}
func newProducer(t *testing.T, cards []Card, config ...Config) (*Producer, *queue.Store) {
t.Helper()
redis := testutil.Redis(t)
store, err := queue.New(redis, "imei:")
if err != nil {
t.Fatal(err)
}
cfg := Config{PageSize: 1}
if len(config) > 0 {
cfg = config[0]
}
producer, err := New(cardScanner{cards: cards}, store, cfg)
if err != nil {
t.Fatal(err)
}
return producer, store
}
// TestProduceEnqueuesEligibleCards 覆盖 §7 T1:全量入队,
// 跳过无通道、无模板、不参与轮询的卡。
func TestProduceEnqueuesEligibleCards(t *testing.T) {
ctx := context.Background()
producer, store := newProducer(t, []Card{
{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}, // 合格
{ID: 2, ICCID: "b", ChanKey: "ch", TempKey: "sim_yd_v5", SyncEnabled: true}, // sync_enabled=1 跳过
{ID: 3, ICCID: "c", ChanKey: "ch"}, // 无模板
{ID: 4, ICCID: "d", ChanKey: "ch", TempKey: "sim_yd_v5"}, // 合格
})
if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "full"}); err != nil {
t.Fatal(err)
}
meta, err := store.GetRunMeta(ctx, "full")
if err != nil {
t.Fatal(err)
}
if !meta.ProducerDone {
t.Fatal("producerDone 应为 true")
}
// 用 ActiveCount 而非 EnqueuedCount:后者是 RunMeta 上的既有死字段
// (只有 RoundStats.EnqueuedCount 会被真实写入),状态链路同样如此。
if meta.ActiveCount != 2 {
t.Fatalf("activeCount = %d, want 2", meta.ActiveCount)
}
claims, err := store.ClaimRun(ctx, "full", "ch", time.Now(), time.Now().Add(time.Minute), 10)
if err != nil {
t.Fatal(err)
}
if len(claims) != 2 {
t.Fatalf("claims = %d, want 2", len(claims))
}
for _, claim := range claims {
if claim.Task.CardID != 1 && claim.Task.CardID != 4 {
t.Fatalf("不应入队 card %d", claim.Task.CardID)
}
if claim.Task.Operation != FullSyncOperation {
t.Fatalf("operation = %q, want %q", claim.Task.Operation, FullSyncOperation)
}
}
}
// TestProduceRePollIncludesCardsThatAlreadyHaveImei 是用户决策的直接回归测试:
// 全量重查(而非只查空值卡),已有 IMEI 的卡也必须入队,否则无法发现换机。
func TestProduceRePollIncludesCardsThatAlreadyHaveImei(t *testing.T) {
ctx := context.Background()
producer, store := newProducer(t, []Card{
{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5", Imei: "865466070817587"},
{ID: 2, ICCID: "b", ChanKey: "ch", TempKey: "sim_yd_v5", Imei: ""},
})
if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "repoll"}); err != nil {
t.Fatal(err)
}
meta, err := store.GetRunMeta(ctx, "repoll")
if err != nil {
t.Fatal(err)
}
if meta.ActiveCount != 2 {
t.Fatalf("全量重查应入队全部合格卡(含已有值),activeCount = %d, want 2", meta.ActiveCount)
}
}
// TestProduceAppliesChannelScope 覆盖 §7 T2:按通道裁剪。
func TestProduceAppliesChannelScope(t *testing.T) {
ctx := context.Background()
producer, store := newProducer(t, []Card{
{ID: 1, ICCID: "a", ChanKey: "173", TempKey: "sim_yd_v5"},
{ID: 2, ICCID: "b", ChanKey: "189", TempKey: "sim_yd_v5"},
{ID: 3, ICCID: "c", ChanKey: "173", TempKey: "sim_yd_v5"},
})
scope := &model.SyncScope{Channels: []string{"173"}, TriggerSource: model.TriggerSourceManual}
if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "scoped", Scope: scope}); err != nil {
t.Fatal(err)
}
meta, err := store.GetRunMeta(ctx, "scoped")
if err != nil {
t.Fatal(err)
}
if meta.ActiveCount != 2 {
t.Fatalf("activeCount = %d, want 2(只入 173 通道)", meta.ActiveCount)
}
if meta.ScopeChannels != 1 {
t.Fatalf("scopeChannels = %d, want 1", meta.ScopeChannels)
}
if meta.ScopeDigest == "" {
t.Fatal("scopeDigest 不应为空")
}
claims, err := store.ClaimRun(ctx, "scoped", "173", time.Now(), time.Now().Add(time.Minute), 10)
if err != nil {
t.Fatal(err)
}
if len(claims) != 2 {
t.Fatalf("173 通道应有 2 张卡,实际 %d", len(claims))
}
}
// TestProduceRejectsInvalidScope 非法 scope 应在建 Run 前失败。
func TestProduceRejectsInvalidScope(t *testing.T) {
ctx := context.Background()
producer, store := newProducer(t, []Card{{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}})
// 超过 200 个通道
tooMany := make([]string, 0, model.MaxScopeChannels+1)
for i := 0; i <= model.MaxScopeChannels; i++ {
tooMany = append(tooMany, "ch"+string(rune('a'+i%26)))
}
// 通道名重复会被去重,故用不重复的
tooMany = tooMany[:0]
for i := 0; i <= model.MaxScopeChannels; i++ {
tooMany = append(tooMany, "ch"+padInt(i))
}
err := producer.Produce(ctx, model.ImeiQueueStartMessage{
RunID: "bad-scope",
Scope: &model.SyncScope{Channels: tooMany},
})
if err == nil {
t.Fatal("超限 scope 应被拒绝")
}
if _, runErr := store.GetRunMeta(ctx, "bad-scope"); runErr == nil {
t.Fatal("非法 scope 不应创建 Run")
}
}
// TestProduceIsIdempotentForSameRun 同一 RunID 重复投递不重复入队。
func TestProduceIsIdempotentForSameRun(t *testing.T) {
ctx := context.Background()
producer, store := newProducer(t, []Card{
{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"},
{ID: 2, ICCID: "b", ChanKey: "ch", TempKey: "sim_yd_v5"},
})
for i := 0; i < 2; i++ {
if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "dup"}); err != nil {
t.Fatal(err)
}
}
meta, err := store.GetRunMeta(ctx, "dup")
if err != nil {
t.Fatal(err)
}
if meta.ActiveCount != 2 {
t.Fatalf("activeCount = %d, want 2(重复投递不应翻倍)", meta.ActiveCount)
}
}
// TestProduceSkipsFinishedRun 终态 Run 不被重投消息复活。
func TestProduceSkipsFinishedRun(t *testing.T) {
ctx := context.Background()
producer, store := newProducer(t, []Card{{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}})
if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "done"}); err != nil {
t.Fatal(err)
}
// 排空队列让 Run 进入终态:先领取并终结任务,再收尾。
claims, err := store.ClaimRun(ctx, "done", "ch", time.Now(), time.Now().Add(time.Minute), 10)
if err != nil || len(claims) != 1 {
t.Fatalf("claims=%v err=%v", claims, err)
}
if _, err = store.FinalizeRunTasks(ctx, claims, []queue.TaskOutcome{{Success: true}}); err != nil {
t.Fatal(err)
}
if completed, err := store.CompleteRunIfIdle(ctx, "done"); err != nil || !completed {
t.Fatalf("complete run: completed=%v err=%v", completed, err)
}
err = producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "done"})
if !errors.Is(err, ErrRunFinished) {
t.Fatalf("终态 Run 重投应返回 ErrRunFinished,实际 %v", err)
}
}
// TestProduceRejectsEmptyRunID 空 RunID 直接失败。
func TestProduceRejectsEmptyRunID(t *testing.T) {
ctx := context.Background()
producer, _ := newProducer(t, []Card{{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}})
if err := producer.Produce(ctx, model.ImeiQueueStartMessage{}); err == nil {
t.Fatal("空 RunID 应被拒绝")
}
}
// TestScopeDigestIsStableAcrossOrdering 摘要对通道顺序不敏感,
// 使「同一批通道以不同顺序请求」得到同一个 digest。
//
// 排序由 NormalizeScope 完成,所以必须走归一化后再算 digest
// (直接对未排序输入调 scopeDigest 得到不同结果属预期行为)。
func TestScopeDigestIsStableAcrossOrdering(t *testing.T) {
a, err := model.NormalizeScope(&model.SyncScope{Channels: []string{"173", "189", "216"}})
if err != nil {
t.Fatal(err)
}
b, err := model.NormalizeScope(&model.SyncScope{Channels: []string{"216", "173", "189"}})
if err != nil {
t.Fatal(err)
}
if scopeDigest(a) != scopeDigest(b) {
t.Fatalf("digest 应对顺序不敏感: %q vs %q", scopeDigest(a), scopeDigest(b))
}
if scopeDigest(a) == "" {
t.Fatal("非空 scope 的 digest 不应为空")
}
if scopeDigest(nil) != "" {
t.Fatal("全量同步的 digest 应为空")
}
}
func padInt(value int) string {
if value == 0 {
return "0"
}
digits := make([]byte, 0, 8)
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value /= 10
}
return string(digits)
}