Files
iotlcm/internal/producer/imei/lock_cancel_test.go
T
MiFaZhan 2bec257289 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),属既有缺陷,本仓未引入亦未修复。
2026-10-22 16:37:56 +08:00

406 lines
13 KiB
Go

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)
}
}