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
+171
View File
@@ -0,0 +1,171 @@
package zset
import (
"context"
"encoding/json"
"fmt"
"time"
)
// AcquireLock obtains a Redis lease only when the key is absent.
func (s *Store) AcquireLock(ctx context.Context, key, token string, ttl time.Duration) (bool, error) {
if key == "" || token == "" {
return false, fmt.Errorf("lock key and token are required")
}
if ttl <= 0 {
return false, fmt.Errorf("lock ttl must be positive")
}
result, err := s.redis.Do(ctx, "SET", key, token, "NX", "PX", ttl.Milliseconds())
if err != nil {
return false, fmt.Errorf("acquire lock: %w", err)
}
return result.String() == "OK", nil
}
// RenewLock extends the lease only when the token still owns the key.
func (s *Store) RenewLock(ctx context.Context, key, token string, ttl time.Duration) (bool, error) {
if key == "" || token == "" {
return false, fmt.Errorf("lock key and token are required")
}
if ttl <= 0 {
return false, fmt.Errorf("lock ttl must be positive")
}
result, err := s.redis.Eval(ctx, renewLockScript, 1, []string{key}, []any{token, ttl.Milliseconds()})
if err != nil {
return false, fmt.Errorf("renew lock: %w", err)
}
return result.Int() == 1, nil
}
// ReleaseLock removes the key only when the token still owns it.
func (s *Store) ReleaseLock(ctx context.Context, key, token string) (bool, error) {
if key == "" || token == "" {
return false, fmt.Errorf("lock key and token are required")
}
result, err := s.redis.Eval(ctx, releaseLockScript, 1, []string{key}, []any{token})
if err != nil {
return false, fmt.Errorf("release lock: %w", err)
}
return result.Int() == 1, nil
}
// AcquireLockOrTakeover acquires key for token, or takes it over when the
// existing lock cannot belong to a live owner of different work.
//
// This exists because a producer that dies (for example on a Redis EOF) leaves
// its lock behind for the remainder of the TTL. A redelivered start message for
// that same run would otherwise fail to acquire the lock, be reported as
// "already running", and be acked and dropped by the consumer - the request
// would disappear without ever reaching the idempotent rescan path that
// hasPendingTasks deliberately allows.
//
// Takeover rules, in order:
//
// 1. No lock: acquire it.
// 2. Structured lock for the same runID: take it over. Owner tokens are
// regenerated per process, so only the runID can identify "the same work".
// 3. Structured lock for a different runID: refuse. Another run is live.
// 4. Bare (legacy) token: take it over only when it is provably stale (see
// below). A bare value can only have been written by a build that predates
// structured locks. Refusing outright would strand the lock for the whole
// TTL and drop the request, which is the exact failure this method exists to
// prevent. Taking it over unconditionally would be unsafe: during a rolling
// deploy an older process still writes and renews a bare token, and stealing
// a live lock lets two producers run at once.
//
// The staleness test for a legacy lock: a live holder renews every TTL/3 (see
// Producer.renewLock), so its remaining TTL never falls below 2*TTL/3. A
// remaining TTL at or below TTL/2 therefore proves the holder stopped renewing.
// A lock with no expiry at all is treated as stale, because every writer in this
// codebase sets one.
//
// runID may be empty, in which case the call degrades to AcquireLock.
func (s *Store) AcquireLockOrTakeover(ctx context.Context, key, token, runID string, ttl time.Duration) (bool, error) {
if key == "" || token == "" {
return false, fmt.Errorf("lock key and token are required")
}
if ttl <= 0 {
return false, fmt.Errorf("lock ttl must be positive")
}
if runID == "" {
return s.AcquireLock(ctx, key, token, ttl)
}
value, err := marshalLockValue(lockValue{Owner: token, RunID: runID})
if err != nil {
return false, err
}
result, err := s.redis.Eval(ctx, acquireOrTakeoverLockScript, 1, []string{key}, []any{value, ttl.Milliseconds()})
if err != nil {
return false, fmt.Errorf("acquire or take over lock: %w", err)
}
return result.Int() == 1, nil
}
// lockValue is the structured lock payload. Older locks hold a bare owner
// token; the scripts accept both so a rolling deploy is safe.
type lockValue struct {
Owner string `json:"owner"`
RunID string `json:"runId,omitempty"`
}
func marshalLockValue(value lockValue) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", fmt.Errorf("encode lock value: %w", err)
}
return string(encoded), nil
}
// renewLockScript extends the lease when the token still owns the key. The
// stored value may be a bare legacy token or a structured lockValue.
const renewLockScript = `
local current = redis.call('GET', KEYS[1])
if not current then return 0 end
local ok, decoded = pcall(cjson.decode, current)
local owner = current
if ok and type(decoded) == 'table' and decoded['owner'] then owner = decoded['owner'] end
if owner ~= ARGV[1] then return 0 end
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
`
// releaseLockScript deletes the key only when the token still owns it.
const releaseLockScript = `
local current = redis.call('GET', KEYS[1])
if not current then return 0 end
local ok, decoded = pcall(cjson.decode, current)
local owner = current
if ok and type(decoded) == 'table' and decoded['owner'] then owner = decoded['owner'] end
if owner ~= ARGV[1] then return 0 end
redis.call('DEL', KEYS[1])
return 1
`
// acquireOrTakeoverLockScript sets the key when free, or replaces it when the
// existing lock either belongs to the same run or is a legacy bare token that
// cannot belong to a live structured-lock holder. Returns 1 on success.
const acquireOrTakeoverLockScript = `
local current = redis.call('GET', KEYS[1])
if not current then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return 1
end
local okNext, wanted = pcall(cjson.decode, ARGV[1])
if not okNext or type(wanted) ~= 'table' or wanted['runId'] == nil then return 0 end
local ok, decoded = pcall(cjson.decode, current)
if not ok or type(decoded) ~= 'table' then
-- Legacy bare token. A live holder renews every TTL/3, so its remaining TTL
-- never drops below 2*TTL/3; at or below TTL/2 the holder has stopped
-- renewing and the lock is stale. A missing expiry is stale too, because
-- every writer sets one. Refuse a still-live legacy lock so a rolling deploy
-- cannot end up with two producers.
local pttl = redis.call('PTTL', KEYS[1])
if pttl > 0 and pttl > math.floor(tonumber(ARGV[2]) / 2) then return 0 end
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return 1
end
if decoded['runId'] == nil then return 0 end
if decoded['runId'] ~= wanted['runId'] then return 0 end
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return 1
`
+189
View File
@@ -0,0 +1,189 @@
package zset
import (
"context"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
func newLockStore(t *testing.T) *Store {
t.Helper()
store, err := New(testutil.Redis(t), "flow:")
if err != nil {
t.Fatal(err)
}
return store
}
func TestAcquireLockOrTakeoverBlocksOtherRuns(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "owner-a", "run-1", time.Minute)
if err != nil || !acquired {
t.Fatalf("first acquire: acquired=%v err=%v", acquired, err)
}
// A different run must not steal the lock.
acquired, err = store.AcquireLockOrTakeover(ctx, "flow:producer:active", "owner-b", "run-2", time.Minute)
if err != nil {
t.Fatal(err)
}
if acquired {
t.Fatal("a different run took over the lock")
}
}
func TestAcquireLockOrTakeoverResumesSameRunAfterCrash(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
// Simulate a producer that died without releasing its lock.
if acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "dead-owner", "run-1", time.Minute); err != nil || !acquired {
t.Fatalf("first acquire: acquired=%v err=%v", acquired, err)
}
// The redelivered message arrives with a fresh owner token but the same run.
acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "new-owner", "run-1", time.Minute)
if err != nil {
t.Fatal(err)
}
if !acquired {
t.Fatal("the same run could not resume after a producer crash")
}
// The new owner must now hold the lock for renew and release.
renewed, err := store.RenewLock(ctx, "flow:producer:active", "new-owner", time.Minute)
if err != nil || !renewed {
t.Fatalf("new owner cannot renew: renewed=%v err=%v", renewed, err)
}
released, err := store.ReleaseLock(ctx, "flow:producer:active", "new-owner")
if err != nil || !released {
t.Fatalf("new owner cannot release: released=%v err=%v", released, err)
}
}
func TestAcquireLockOrTakeoverEmptyRunIDDegradesToAcquireLock(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
if acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "owner-a", "", time.Minute); err != nil || !acquired {
t.Fatalf("first acquire: acquired=%v err=%v", acquired, err)
}
acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "owner-b", "", time.Minute)
if err != nil {
t.Fatal(err)
}
if acquired {
t.Fatal("an empty runID must not permit takeover")
}
}
// TestAcquireLockOrTakeoverRefusesLiveLegacyLock covers the rolling-deploy case:
// an older build still holds a bare token and keeps renewing it. Stealing that
// lock would let two producers run at once, so it must be refused.
func TestAcquireLockOrTakeoverRefusesLiveLegacyLock(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
// A live legacy holder renews every TTL/3, so its remaining TTL stays above
// TTL/2. AcquireLock writes the bare token with the full TTL, which is
// indistinguishable from a just-renewed lock.
if acquired, err := store.AcquireLock(ctx, "flow:producer:active", "legacy-owner", time.Minute); err != nil || !acquired {
t.Fatalf("legacy acquire: acquired=%v err=%v", acquired, err)
}
acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "new-owner", "run-1", time.Minute)
if err != nil {
t.Fatal(err)
}
if acquired {
t.Fatal("a still-live legacy lock must not be taken over: that would let two producers run at once")
}
// The legacy owner keeps the lock.
if renewed, err := store.RenewLock(ctx, "flow:producer:active", "legacy-owner", time.Minute); err != nil || !renewed {
t.Fatalf("legacy owner lost the lock: renewed=%v err=%v", renewed, err)
}
}
// TestAcquireLockOrTakeoverTakesOverStaleLegacyLock covers the case the takeover
// exists for: the legacy writer died, stopped renewing, and its TTL has fallen
// past the point a live holder could ever reach.
func TestAcquireLockOrTakeoverTakesOverStaleLegacyLock(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
// A short TTL lets the test wait for the stale window without sleeping a
// full production TTL. After ~60% of the TTL has elapsed the remaining TTL is
// below TTL/2, which a renewing holder can never reach.
if acquired, err := store.AcquireLock(ctx, "flow:producer:active", "legacy-owner", 2*time.Second); err != nil || !acquired {
t.Fatalf("legacy acquire: acquired=%v err=%v", acquired, err)
}
time.Sleep(1300 * time.Millisecond)
acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "new-owner", "run-1", time.Minute)
if err != nil {
t.Fatal(err)
}
if !acquired {
t.Fatal("a stale legacy lock must be taken over; refusing strands the key and drops the request")
}
// The new owner must now hold the lock, and the old token must not.
if renewed, err := store.RenewLock(ctx, "flow:producer:active", "new-owner", time.Minute); err != nil || !renewed {
t.Fatalf("new owner cannot renew: renewed=%v err=%v", renewed, err)
}
if released, err := store.ReleaseLock(ctx, "flow:producer:active", "legacy-owner"); err != nil || released {
t.Fatalf("legacy owner must no longer hold the lock: released=%v err=%v", released, err)
}
}
// TestAcquireLockOrTakeoverTakesOverLegacyLockWithoutExpiry treats a legacy lock
// that has no expiry as stale: every writer in this codebase sets one.
func TestAcquireLockOrTakeoverTakesOverLegacyLockWithoutExpiry(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
// Simulate a legacy lock written without a TTL.
if _, err := store.RedisClient().Do(ctx, "SET", "flow:producer:active", "legacy-owner"); err != nil {
t.Fatal(err)
}
acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "new-owner", "run-1", time.Minute)
if err != nil {
t.Fatal(err)
}
if !acquired {
t.Fatal("a legacy lock with no expiry must be treated as stale and taken over")
}
}
func TestAcquireLockOrTakeoverBlocksDifferentStructuredRun(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
// A structured lock for a different run means another run is genuinely live.
if acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "owner-a", "run-1", time.Minute); err != nil || !acquired {
t.Fatalf("first acquire: acquired=%v err=%v", acquired, err)
}
acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "owner-b", "run-2", time.Minute)
if err != nil {
t.Fatal(err)
}
if acquired {
t.Fatal("a structured lock held by a different run must not be taken over")
}
}
func TestAcquireLockOrTakeoverRejectsNonOwnerRelease(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
if acquired, err := store.AcquireLockOrTakeover(ctx, "flow:producer:active", "owner-a", "run-1", time.Minute); err != nil || !acquired {
t.Fatalf("acquire: acquired=%v err=%v", acquired, err)
}
released, err := store.ReleaseLock(ctx, "flow:producer:active", "owner-b")
if err != nil {
t.Fatal(err)
}
if released {
t.Fatal("a non-owner released the lock")
}
}
@@ -0,0 +1,77 @@
package zset
import (
"context"
"reflect"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
func TestMarkProducerAbandonedAtomicGuards(t *testing.T) {
store, err := New(testutil.Redis(t), "abandoned:")
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
for _, test := range []struct {
name, status, syncType string
done, want bool
}{
{name: "producing", status: "producing", want: true},
{name: "blocked_running", status: "running", want: true},
{name: "producing_done", status: "producing", done: true},
{name: "running_done", status: "running", done: true},
{name: "producing_replay", status: "producing", syncType: "replay"},
{name: "running_replay", status: "running", syncType: "replay"},
{name: "completed", status: "completed"},
{name: "completed_with_failures", status: "completed_with_failures"},
{name: "unknown_status", status: "paused"},
{name: "missing"},
} {
t.Run(test.name, func(t *testing.T) {
key, _ := store.RunMetaKey(test.name)
if test.status != "" {
if err := store.CreateRun(ctx, RunMeta{RunID: test.name, Status: test.status, ProducerDone: test.done, SyncType: test.syncType, ActiveCount: 3, InflightCount: 1, NextRoundCount: 2, FailedCount: 4}); err != nil {
t.Fatal(err)
}
if _, err := store.RedisClient().Do(ctx, "HSET", key, "blocked:a", "1"); err != nil {
t.Fatal(err)
}
}
before, err := store.RedisClient().Do(ctx, "HGETALL", key)
if err != nil {
t.Fatal(err)
}
promoted, err := store.MarkProducerAbandoned(ctx, test.name)
if err != nil || promoted != test.want {
t.Fatalf("promoted=%t want=%t err=%v", promoted, test.want, err)
}
after, err := store.RedisClient().Do(ctx, "HGETALL", key)
if err != nil {
t.Fatal(err)
}
expected := before.MapStrStr()
actual := after.MapStrStr()
if test.want {
if actual["abandonedAt"] == "" || actual["abandonedAt"] != actual["producerCompletedAt"] {
t.Fatalf("missing abandonment timestamps: %v", actual)
}
expected["producerDone"] = "1"
expected["status"] = "running"
expected["abandonedAt"] = actual["abandonedAt"]
expected["producerCompletedAt"] = actual["producerCompletedAt"]
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("unexpected metadata change: want=%v got=%v", expected, actual)
}
if again, err := store.MarkProducerAbandoned(ctx, test.name); err != nil || again {
t.Fatalf("repeated promotion=%t err=%v", again, err)
}
unchanged, err := store.RedisClient().Do(ctx, "HGETALL", key)
if err != nil || !reflect.DeepEqual(actual, unchanged.MapStrStr()) {
t.Fatalf("idempotent promotion changed metadata: %v err=%v", unchanged, err)
}
})
}
}
+64
View File
@@ -0,0 +1,64 @@
package zset
import (
"context"
"strings"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/errors/gerror"
)
var ErrProducerLeaseLost = gerror.New("producer lease lost")
// WithIMEIProducerLease returns a private producer view; the shared store and
// other producer/consumer protocols are unchanged. The IMEI caller must reject
// Redis Cluster before using this view: the global lock and run keys cross slots.
func (s *Store) WithIMEIProducerLease(key, owner, runID string) (*Store, error) {
if key == "" || owner == "" || runID == "" {
return nil, gerror.New("producer lease key, owner and runId are required")
}
view := *s
view.redis = &imeiProducerRedis{RedisClient: s.redis, key: key, owner: owner, runID: runID}
return &view, nil
}
type imeiProducerRedis struct {
RedisClient
key, owner, runID string
}
func (r *imeiProducerRedis) Eval(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
leaseKeys := append(append([]string(nil), keys...), r.key)
leaseArgs := append(append([]any(nil), args...), r.owner, r.runID)
result, err := r.RedisClient.Eval(ctx, imeiProducerLeaseGuard+script, numKeys+1, leaseKeys, leaseArgs)
if err != nil && strings.Contains(err.Error(), "IMEI_PRODUCER_LEASE_LOST") {
return nil, gerror.Wrap(ErrProducerLeaseLost, "reject stale IMEI producer write")
}
return result, err
}
func (r *imeiProducerRedis) Do(ctx context.Context, command string, args ...any) (*gvar.Var, error) {
switch strings.ToUpper(command) {
case "HGETALL", "SMEMBERS": // The producer's metadata/capacity reads are not mutations.
return r.RedisClient.Do(ctx, command, args...)
case "SADD", "HSET": // CreateRun also writes the index and metadata extensions.
commandArgs := append([]any{command}, args...)
return r.Eval(ctx, "return redis.call(unpack(ARGV))", 0, nil, commandArgs)
default:
return nil, gerror.Newf("unsupported command in IMEI producer lease view: %s", command)
}
}
// Remove guard-only arguments before executing the original queue script, so
// task-pair loops and key indexing retain their original semantics.
const imeiProducerLeaseGuard = `
local leaseRunID = table.remove(ARGV)
local leaseOwner = table.remove(ARGV)
local leaseKey = table.remove(KEYS)
local current = redis.call('GET', leaseKey)
if not current then return redis.error_reply('IMEI_PRODUCER_LEASE_LOST') end
local ok, lease = pcall(cjson.decode, current)
if not ok or type(lease) ~= 'table' or lease['owner'] ~= leaseOwner or lease['runId'] ~= leaseRunID then
return redis.error_reply('IMEI_PRODUCER_LEASE_LOST')
end
`
@@ -0,0 +1,94 @@
package zset
import (
"context"
"errors"
"testing"
"time"
)
func TestIMEIProducerLeaseRejectsStaleWrites(t *testing.T) {
for _, invalid := range []string{"missing", "expired", "other_owner", "other_run", "legacy", "invalid_json"} {
t.Run(invalid, func(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
const key = "imei:producer:active"
view, err := store.WithIMEIProducerLease(key, "owner", "run")
if err != nil {
t.Fatal(err)
}
if err := store.CreateRun(ctx, RunMeta{RunID: "run"}); err != nil {
t.Fatal(err)
}
switch invalid {
case "expired":
if _, err := store.redis.Do(ctx, "SET", key, `{"owner":"owner","runId":"run"}`, "PX", 1); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
case "other_owner":
_, err = store.redis.Do(ctx, "SET", key, `{"owner":"new-owner","runId":"run"}`)
case "other_run":
_, err = store.redis.Do(ctx, "SET", key, `{"owner":"owner","runId":"other-run"}`)
case "legacy":
_, err = store.redis.Do(ctx, "SET", key, "owner")
case "invalid_json":
_, err = store.redis.Do(ctx, "SET", key, "{")
}
if err != nil {
t.Fatal(err)
}
_, err = view.EnqueueRunBatch(ctx, "run", "ch", []Task{{TaskID: "old", RunID: "run", ChanKey: "ch"}}, time.Now())
if !errors.Is(err, ErrProducerLeaseLost) {
t.Fatalf("enqueue did not report lease loss: %v", err)
}
if err := view.MarkProducerDone(ctx, "run"); !errors.Is(err, ErrProducerLeaseLost) {
t.Fatalf("done did not report lease loss: %v", err)
}
if err := view.CreateRun(ctx, RunMeta{RunID: "run", ScopeDigest: "stale"}); !errors.Is(err, ErrProducerLeaseLost) {
t.Fatalf("metadata write did not report lease loss: %v", err)
}
meta, err := store.GetRunMeta(ctx, "run")
if err != nil || meta.ActiveCount != 0 || meta.ProducerDone || meta.ScopeDigest != "" {
t.Fatalf("stale state mutation: %+v, %v", meta, err)
}
channels, err := store.RunChannels(ctx, "run")
if err != nil || len(channels) != 0 {
t.Fatalf("stale channel mutation: %v, %v", channels, err)
}
// The private view never changes the consumer/other-producer store.
if added, err := store.EnqueueRunBatch(ctx, "run", "ch", []Task{{TaskID: "next", RunID: "run", ChanKey: "ch"}}, time.Now()); err != nil || added != 1 {
t.Fatalf("shared queue semantics changed: %d, %v", added, err)
}
})
}
}
func TestIMEIProducerLeasePreservesValidOwnerSemantics(t *testing.T) {
store := newLockStore(t)
ctx := context.Background()
const key = "imei:producer:active"
if ok, err := store.AcquireLockOrTakeover(ctx, key, "owner", "run", time.Minute); err != nil || !ok {
t.Fatalf("acquire: %v, %v", ok, err)
}
view, err := store.WithIMEIProducerLease(key, "owner", "run")
if err != nil {
t.Fatal(err)
}
if err := view.CreateRun(ctx, RunMeta{RunID: "run", ScopeDigest: "scope"}); err != nil {
t.Fatal(err)
}
tasks := []Task{{TaskID: "one", RunID: "run", ChanKey: "ch"}, {TaskID: "two", RunID: "run", ChanKey: "ch"}}
for _, want := range []int{2, 0} {
if added, err := view.EnqueueRunBatch(ctx, "run", "ch", tasks, time.Now()); err != nil || added != want {
t.Fatalf("idempotent batch pairs: %d, %v; want %d", added, err, want)
}
}
if err := view.MarkProducerDone(ctx, "run"); err != nil {
t.Fatal(err)
}
meta, err := store.GetRunMeta(ctx, "run")
if err != nil || meta.ActiveCount != 2 || !meta.ProducerDone || meta.ScopeDigest != "scope" {
t.Fatalf("valid owner state: %+v, %v", meta, err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package zset
import (
"context"
"github.com/gogf/gf/v2/errors/gerror"
)
// ProducerState is a small internal read view, not a new Run wire format.
// ActiveCount includes inflight; never add InflightCount to capacity usage.
type ProducerState struct {
Status string
ProducerDone bool
CurrentRound int
ActiveCount int64
Blocked bool
}
func (s *Store) ReadProducerState(ctx context.Context, runID, chanKey string) (ProducerState, error) {
key, err := s.RunMetaKey(runID)
if err != nil {
return ProducerState{}, err
}
result, err := s.redis.Do(ctx, "HMGET", key, "runId", "status", "producerDone", "currentRound", "activeCount", "blocked:"+chanKey)
if err != nil {
return ProducerState{}, gerror.Wrap(err, "read producer state")
}
values := result.Strings()
if len(values) != 6 || values[0] == "" {
return ProducerState{}, gerror.Wrapf(ErrRunNotFound, "run %q", runID)
}
state := ProducerState{Status: values[1], ProducerDone: parseBool(values[2]), CurrentRound: parseInt(values[3]), ActiveCount: parseInt64(values[4]), Blocked: parseBool(values[5])}
if state.CurrentRound <= 0 || state.ActiveCount < 0 {
return ProducerState{}, gerror.New("invalid producer run counters")
}
return state, nil
}
// ExistingRunTasks uses one bounded HMGET for a producer page. All task states
// (queued, leased, waiting, success and failed) count as already admitted.
func (s *Store) ExistingRunTasks(ctx context.Context, runID string, tasks []Task) ([]bool, error) {
if len(tasks) == 0 {
return nil, nil
}
key, err := s.TaskStateKey(runID)
if err != nil {
return nil, err
}
args := make([]any, 1, len(tasks)+1)
args[0] = key
for _, task := range tasks {
if task.TaskID == "" || task.RunID != runID {
return nil, gerror.New("invalid task identity in producer state read")
}
args = append(args, task.TaskID)
}
result, err := s.redis.Do(ctx, "HMGET", args...)
if err != nil {
return nil, gerror.Wrap(err, "read existing run tasks")
}
values := result.Strings()
if len(values) != len(tasks) {
return nil, gerror.New("incomplete HMGET task state response")
}
exists := make([]bool, len(tasks))
for i, value := range values {
exists[i] = value != ""
}
return exists, nil
}
@@ -0,0 +1,45 @@
package zset
import (
"context"
"errors"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
func TestProducerStateReadView(t *testing.T) {
store, err := New(testutil.Redis(t), "producer-state:")
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err = store.ReadProducerState(ctx, "missing", "a"); !errors.Is(err, ErrRunNotFound) {
t.Fatalf("missing run err=%v", err)
}
if err = store.CreateRun(ctx, RunMeta{RunID: "state", Status: "producing", ActiveCount: 3, InflightCount: 2}); err != nil {
t.Fatal(err)
}
metaKey, _ := store.RunMetaKey("state")
_, _ = store.RedisClient().Do(ctx, "HSET", metaKey, "blocked:a", "1")
state, err := store.ReadProducerState(ctx, "state", "a")
if err != nil || state.ActiveCount != 3 || state.ProducerDone || state.CurrentRound != 1 || !state.Blocked {
t.Fatalf("state=%+v err=%v", state, err)
}
// Every state deduplicates admission, including terminal and leased tasks.
statesKey, _ := store.TaskStateKey("state")
_, _ = store.RedisClient().Do(ctx, "HSET", statesKey, "success", "succeeded", "leased", "claimed", "waiting", "waiting_next_round")
exists, err := store.ExistingRunTasks(ctx, "state", []Task{{TaskID: "success", RunID: "state"}, {TaskID: "leased", RunID: "state"}, {TaskID: "waiting", RunID: "state"}, {TaskID: "new", RunID: "state"}})
if err != nil || len(exists) != 4 || !exists[0] || !exists[1] || !exists[2] || exists[3] {
t.Fatalf("exists=%v err=%v", exists, err)
}
if _, err = store.ExistingRunTasks(ctx, "state", []Task{{RunID: "foreign", TaskID: "x"}}); err == nil {
t.Fatal("foreign task accepted")
}
for _, args := range [][]any{{"currentRound", 0}, {"currentRound", 1, "activeCount", -1}} {
_, _ = store.RedisClient().Do(ctx, "HSET", append([]any{metaKey}, args...)...)
if _, err = store.ReadProducerState(ctx, "state", "a"); err == nil {
t.Fatal("invalid run counters accepted")
}
}
}
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
package zset
import (
"context"
"fmt"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
func newCoveredRun(t *testing.T, prefix, run string, count int) (*Store, context.Context) {
t.Helper()
store, err := New(testutil.Redis(t), prefix)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := store.CreateRun(ctx, RunMeta{RunID: run}); err != nil {
t.Fatal(err)
}
tasks := make([]Task, count)
for i := range tasks {
tasks[i] = Task{RunID: run, TaskID: fmt.Sprintf("%s:%d", run, i), CardID: int64(i + 1), ChanKey: "channel", TempKey: "sim_yd_v5", ICCID: fmt.Sprintf("card-%d", i)}
}
if n, err := store.EnqueueRunBatch(ctx, run, "channel", tasks, time.Now()); err != nil || n != count {
t.Fatalf("enqueue %d: %v", n, err)
}
if err := store.MarkProducerDone(ctx, run); err != nil {
t.Fatal(err)
}
return store, ctx
}
// TestCoveredOutcomeStaysOutOfFailurePool pins the queue-side contract of a
// covered result: the task leaves the round like a success, the run counts it
// as completed work, and nothing is written to the failure hash or failedCount.
// Code 14000 ("carrier has no record of this card") reaches the queue through
// this path, so a regression here restores the 4900-entry failure pool seen in
// production on 2026-10-20.
func TestCoveredOutcomeStaysOutOfFailurePool(t *testing.T) {
store, ctx := newCoveredRun(t, "covered:", "run", 3)
now := time.Now()
claims, err := store.ClaimRun(ctx, "run", "channel", now, now.Add(time.Minute), 3)
if err != nil || len(claims) != 3 {
t.Fatalf("claim: %d %v", len(claims), err)
}
outcomes := []TaskOutcome{
{Success: true},
{Covered: true, ErrorClass: "no_record", Code: 14000, Message: "系统无记录,请检查输入参数是否正确"},
{Covered: true, ErrorClass: "data_invalid", Message: "carrier returned no imei value"},
}
if n, err := store.FinalizeRunTasks(ctx, claims, outcomes); err != nil || n != 3 {
t.Fatalf("finalize: %d %v", n, err)
}
failureKey, _ := store.FailedKey("run")
size, err := store.RedisClient().Do(ctx, "HLEN", failureKey)
if err != nil || size.Int64() != 0 {
t.Fatalf("failure pool entries = %d err=%v, want 0", size.Int64(), err)
}
meta, err := store.GetRunMeta(ctx, "run")
if err != nil {
t.Fatal(err)
}
if meta.FailedCount != 0 || meta.SuccessCount != 3 {
t.Fatalf("counters: success=%d failed=%d, want 3/0", meta.SuccessCount, meta.FailedCount)
}
// The round was still advanced: covered tasks must free the capacity they
// claimed, otherwise the run can never complete.
if meta.ActiveCount != 0 || meta.InflightCount != 0 || meta.NextRoundCount != 0 {
t.Fatalf("queue capacity not released: %+v", meta)
}
stateKey, _ := store.TaskStateKey("run")
state, err := store.RedisClient().Do(ctx, "HGET", stateKey, "run:1")
if err != nil {
t.Fatal(err)
}
values := state.MapStrAny()
if values["status"] != taskStatusCovered || fmt.Sprint(values["lastCode"]) != "14000" {
t.Fatalf("covered task state = %v", values)
}
completed, err := store.CompleteRunIfIdle(ctx, "run")
if err != nil || !completed {
t.Fatalf("complete: %t %v", completed, err)
}
if meta, err = store.GetRunMeta(ctx, "run"); err != nil || meta.Status != "completed" {
t.Fatalf("status = %q err=%v, want completed (no failures)", meta.Status, err)
}
}
// TestFailureOutcomeStillCountsAsFailure is the guard rail for the test above:
// only Covered is exempt. An ordinary terminal failure keeps the historical
// failure-pool behaviour.
func TestFailureOutcomeStillCountsAsFailure(t *testing.T) {
store, ctx := newCoveredRun(t, "covered-fail:", "run", 2)
now := time.Now()
claims, err := store.ClaimRun(ctx, "run", "channel", now, now.Add(time.Minute), 2)
if err != nil || len(claims) != 2 {
t.Fatalf("claim: %d %v", len(claims), err)
}
outcomes := []TaskOutcome{
{Covered: true, ErrorClass: "no_record", Code: 14000},
{ErrorClass: "business", FailureType: "business", Code: 11011, Message: "invalid card"},
}
if n, err := store.FinalizeRunTasks(ctx, claims, outcomes); err != nil || n != 2 {
t.Fatalf("finalize: %d %v", n, err)
}
failureKey, _ := store.FailedKey("run")
size, err := store.RedisClient().Do(ctx, "HLEN", failureKey)
if err != nil || size.Int64() != 1 {
t.Fatalf("failure pool entries = %d err=%v, want 1", size.Int64(), err)
}
exists, err := store.RedisClient().Do(ctx, "HEXISTS", failureKey, "run:1")
if err != nil || exists.Int() != 1 {
t.Fatalf("the business failure is missing from the pool: %v %v", exists, err)
}
meta, err := store.GetRunMeta(ctx, "run")
if err != nil || meta.FailedCount != 1 || meta.SuccessCount != 1 {
t.Fatalf("counters: %+v err=%v", meta, err)
}
}
+21
View File
@@ -0,0 +1,21 @@
package zset
import "testing"
// TestFailureFromOutcomeCarriesPackageCode guards the daily-usage replay path:
// the TDengine child table stores the package code and the diagnosis page reads
// it, so a failed card must survive into the failure pool with its package code
// intact, or the replayed write would wipe it to empty.
func TestFailureFromOutcomeCarriesPackageCode(t *testing.T) {
record := failureFromOutcome(
Task{TaskID: "r:op:42", RunID: "r", Operation: "op", QueryMonth: "20261009", ChanKey: "217", TempKey: "sim_yd_v5", CardID: 42, ICCID: "89860042", PackageCode: "PKG-A"},
TaskOutcome{Retryable: false, ErrorClass: "business", Code: 4242, Message: "boom"},
3,
)
if record.PackageCode != "PKG-A" {
t.Fatalf("failure record lost the package code: %+v", record)
}
if record.QueryMonth != "20261009" {
t.Fatalf("failure record lost the daily period: %+v", record)
}
}
+424
View File
@@ -0,0 +1,424 @@
package zset
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/container/gvar"
)
// RedisClient is the small Redis surface required by Store. Keeping this
// interface here makes the claim/ack protocol testable without a live Redis.
type RedisClient interface {
Do(ctx context.Context, command string, args ...any) (*gvar.Var, error)
Eval(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error)
}
const defaultPrefix = "flow:"
// Task is the payload stored as a ZSet member. The member itself is retained
// so a task can be recovered after a consumer process exits.
type Task struct {
TaskID string `json:"taskId"`
RunID string `json:"runId"`
Operation string `json:"operation"`
// QueryMonth is the run's period identifier, not necessarily a month.
// Monthly usage sync stores yyyyMM; daily usage sync stores yyyyMMdd.
// The whole queue, run, archive and replay path only ever copies this
// string, so a period change needs no schema change. Do not parse it as a
// month without checking the operation first.
QueryMonth string `json:"queryMonth"`
ChanKey string `json:"chanKey"`
TempKey string `json:"tempKey"`
Operator int `json:"operator,omitempty"`
CardID int64 `json:"cardId"`
ICCID string `json:"iccid"`
MSISDN string `json:"msisdn"`
IMSI string `json:"imsi"`
// PackageCode is the card's current package at production time. Daily
// usage writes it to the TDengine child table where the diagnosis page
// reads it back. It is additive: older members simply decode as empty.
PackageCode string `json:"packageCode,omitempty"`
// NextExecuteAt is the ZSet score and is intentionally excluded from the
// member payload so retrying the same producer run remains idempotent.
NextExecuteAt int64 `json:"-"`
}
// ClaimedTask is returned after a successful Lua lease claim.
type ClaimedTask struct {
Task Task
Member string
LeaseToken string
LeaseUntil int64
// Round is populated by ClaimRun. Legacy channel-scoped claims leave it
// zero and continue using the original lease keys.
Round int
}
// StableTaskID is the logical id used for idempotency across producer retries.
func StableTaskID(runID, operation string, cardID int64) string {
return fmt.Sprintf("%s:%s:%d", runID, operation, cardID)
}
// Store owns the Redis key and lease protocol for one task namespace.
type Store struct {
redis RedisClient
prefix string
owner string
archiveEnabled bool
}
// RedisClient exposes the narrow Redis surface for adjacent full-sync
// repositories. Callers must continue to use Store key builders for namespacing.
func (s *Store) RedisClient() RedisClient { return s.redis }
// Prefix returns the configured Redis namespace prefix.
func (s *Store) Prefix() string { return s.prefix }
// EnableArchiveIndex makes terminal runs discoverable by the optional JSON
// archive worker. It is opt-in so the legacy queue path remains unchanged.
func (s *Store) EnableArchiveIndex(enabled bool) { s.archiveEnabled = enabled }
func New(redis RedisClient, prefix string) (*Store, error) {
if redis == nil {
return nil, fmt.Errorf("redis client is nil")
}
if strings.TrimSpace(prefix) == "" {
prefix = defaultPrefix
}
return &Store{redis: redis, prefix: prefix, owner: newOwner()}, nil
}
func (s *Store) Owner() string { return s.owner }
func (s *Store) Key(chanKey string) (string, error) {
if strings.TrimSpace(chanKey) == "" {
return "", fmt.Errorf("chanKey is empty")
}
return s.prefix + chanKey, nil
}
func (s *Store) leaseKey(chanKey string) (string, error) {
if strings.TrimSpace(chanKey) == "" {
return "", fmt.Errorf("chanKey is empty")
}
return s.prefix + "lease:" + chanKey, nil
}
func (s *Store) blockedKey(chanKey string) (string, error) {
if strings.TrimSpace(chanKey) == "" {
return "", fmt.Errorf("chanKey is empty")
}
return s.prefix + "blocked:" + chanKey, nil
}
func (s *Store) ChannelsKey() string { return s.prefix + "channels" }
func (s *Store) RegisterChannel(ctx context.Context, chanKey string) error {
if strings.TrimSpace(chanKey) == "" {
return fmt.Errorf("chanKey is empty")
}
if _, err := s.redis.Do(ctx, "SADD", s.ChannelsKey(), chanKey); err != nil {
return fmt.Errorf("register channel: %w", err)
}
return nil
}
func (s *Store) ActiveChannels(ctx context.Context) ([]string, error) {
result, err := s.redis.Do(ctx, "SMEMBERS", s.ChannelsKey())
if err != nil {
return nil, fmt.Errorf("list active channels: %w", err)
}
return result.Strings(), nil
}
// Enqueue adds or replaces a task at the requested execution time. A
// deterministic TaskID lets a producer retry its write without creating a
// second logical task.
func (s *Store) Enqueue(ctx context.Context, task Task, nextExecuteAt time.Time) error {
_, err := s.EnqueueBatch(ctx, task.ChanKey, []Task{task}, nextExecuteAt)
return err
}
// EnqueueBatch registers one channel and writes all of its tasks atomically.
// It returns zero when the channel has already been stopped for this run.
func (s *Store) EnqueueBatch(ctx context.Context, chanKey string, tasks []Task, nextExecuteAt time.Time) (int, error) {
if len(tasks) == 0 {
return 0, nil
}
key, err := s.Key(chanKey)
if err != nil {
return 0, err
}
blockedKey, err := s.blockedKey(chanKey)
if err != nil {
return 0, err
}
if nextExecuteAt.IsZero() {
nextExecuteAt = time.Now()
}
runID := tasks[0].RunID
args := make([]any, 0, len(tasks)+3)
args = append(args, chanKey, runID, nextExecuteAt.UnixMilli())
for index := range tasks {
task := tasks[index]
if task.TaskID == "" {
return 0, fmt.Errorf("taskId is empty")
}
if task.ChanKey != chanKey {
return 0, fmt.Errorf("task chanKey %q does not match batch chanKey %q", task.ChanKey, chanKey)
}
if task.RunID != runID {
return 0, fmt.Errorf("task runId %q does not match batch runId %q", task.RunID, runID)
}
task.NextExecuteAt = nextExecuteAt.UnixMilli()
member, marshalErr := json.Marshal(task)
if marshalErr != nil {
return 0, fmt.Errorf("marshal task: %w", marshalErr)
}
args = append(args, string(member))
}
result, err := s.redis.Eval(ctx, enqueueBatchScript, 3, []string{key, s.ChannelsKey(), blockedKey}, args)
if err != nil {
return 0, fmt.Errorf("enqueue task batch: %w", err)
}
return result.Int(), nil
}
// Claim atomically leases due tasks. It intentionally does not remove the
// members: a process crash leaves them recoverable after the lease expires.
func (s *Store) Claim(ctx context.Context, chanKey string, now time.Time, leaseUntil time.Time, limit int) ([]ClaimedTask, error) {
if limit <= 0 {
return nil, nil
}
key, err := s.Key(chanKey)
if err != nil {
return nil, err
}
leaseKey, err := s.leaseKey(chanKey)
if err != nil {
return nil, err
}
if now.IsZero() {
now = time.Now()
}
if leaseUntil.IsZero() || !leaseUntil.After(now) {
return nil, fmt.Errorf("leaseUntil must be after now")
}
result, err := s.redis.Eval(ctx, claimScript, 2, []string{key, leaseKey}, []any{now.UnixMilli(), leaseUntil.UnixMilli(), limit, s.owner})
if err != nil {
return nil, fmt.Errorf("claim tasks: %w", err)
}
values := result.Vars()
if len(values)%2 != 0 {
return nil, fmt.Errorf("claim returned odd number of values: %d", len(values))
}
tasks := make([]ClaimedTask, 0, len(values)/2)
for i := 0; i < len(values); i += 2 {
member := values[i].String()
var task Task
if err := json.Unmarshal([]byte(member), &task); err != nil {
return nil, fmt.Errorf("decode claimed task: %w", err)
}
tasks = append(tasks, ClaimedTask{
Task: task,
Member: member,
LeaseToken: values[i+1].String(),
LeaseUntil: leaseUntil.UnixMilli(),
})
}
return tasks, nil
}
// Renew extends a lease only when the caller still owns it.
func (s *Store) Renew(ctx context.Context, task ClaimedTask, leaseUntil time.Time) (bool, error) {
count, err := s.RenewBatch(ctx, []ClaimedTask{task}, leaseUntil)
return count == 1, err
}
// RenewBatch extends every lease in one Redis round trip. The Lua script
// validates the complete batch before changing any score, so callers never
// continue with only part of a claimed batch.
func (s *Store) RenewBatch(ctx context.Context, tasks []ClaimedTask, leaseUntil time.Time) (int, error) {
if len(tasks) == 0 {
return 0, nil
}
if leaseUntil.IsZero() {
return 0, fmt.Errorf("leaseUntil is zero")
}
chanKey := tasks[0].Task.ChanKey
key, err := s.Key(chanKey)
if err != nil {
return 0, err
}
leaseKey, err := s.leaseKey(chanKey)
if err != nil {
return 0, err
}
args := make([]any, 0, len(tasks)*2+1)
args = append(args, leaseUntil.UnixMilli())
for _, task := range tasks {
if task.Task.ChanKey != chanKey {
return 0, fmt.Errorf("renew batch contains multiple channels")
}
args = append(args, task.Member, task.LeaseToken)
}
result, err := s.redis.Eval(ctx, renewBatchScript, 2, []string{key, leaseKey}, args)
if err != nil {
return 0, fmt.Errorf("renew task batch lease: %w", err)
}
return result.Int(), nil
}
// Ack removes a task only when the lease token still matches. A stale worker
// cannot delete a task reclaimed by another worker.
func (s *Store) Ack(ctx context.Context, task ClaimedTask) (bool, error) {
count, err := s.AckBatch(ctx, []ClaimedTask{task})
return count == 1, err
}
// AckBatch removes every task whose lease token is still owned by this
// consumer. A partial count means at least one lease expired or was reclaimed.
func (s *Store) AckBatch(ctx context.Context, tasks []ClaimedTask) (int, error) {
if len(tasks) == 0 {
return 0, nil
}
chanKey := tasks[0].Task.ChanKey
key, err := s.Key(chanKey)
if err != nil {
return 0, err
}
leaseKey, err := s.leaseKey(chanKey)
if err != nil {
return 0, err
}
args := make([]any, 0, len(tasks)*2)
for _, task := range tasks {
if task.Task.ChanKey != chanKey {
return 0, fmt.Errorf("ack batch contains multiple channels")
}
args = append(args, task.Member, task.LeaseToken)
}
result, err := s.redis.Eval(ctx, ackBatchScript, 2, []string{key, leaseKey}, args)
if err != nil {
return 0, fmt.Errorf("ack task batch: %w", err)
}
return result.Int(), nil
}
// BlockRun stops one channel for the current run and removes its queued and
// leased tasks. Producer batches for the same run are rejected atomically.
func (s *Store) BlockRun(ctx context.Context, chanKey, runID string) (int64, error) {
if strings.TrimSpace(runID) == "" {
return 0, fmt.Errorf("runId is empty")
}
key, err := s.Key(chanKey)
if err != nil {
return 0, err
}
leaseKey, err := s.leaseKey(chanKey)
if err != nil {
return 0, err
}
blockedKey, err := s.blockedKey(chanKey)
if err != nil {
return 0, err
}
result, err := s.redis.Eval(ctx, blockRunScript, 3, []string{key, leaseKey, blockedKey}, []any{runID})
if err != nil {
return 0, fmt.Errorf("block channel run: %w", err)
}
return result.Int64(), nil
}
func (s *Store) Size(ctx context.Context, chanKey string) (int64, error) {
key, err := s.Key(chanKey)
if err != nil {
return 0, err
}
result, err := s.redis.Do(ctx, "ZCARD", key)
if err != nil {
return 0, fmt.Errorf("get task count: %w", err)
}
return result.Int64(), nil
}
func newOwner() string {
var bytes [12]byte
if _, err := rand.Read(bytes[:]); err == nil {
return hex.EncodeToString(bytes[:])
}
return fmt.Sprintf("consumer-%d", time.Now().UnixNano())
}
const claimScript = `
local members = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[3])
local result = {}
local serverTime = redis.call('TIME')
for index, member in ipairs(members) do
local token = ARGV[4] .. ':' .. serverTime[1] .. ':' .. serverTime[2] .. ':' .. index
redis.call('ZADD', KEYS[1], ARGV[2], member)
redis.call('HSET', KEYS[2], member, token)
table.insert(result, member)
table.insert(result, token)
end
return result
`
const enqueueBatchScript = `
if redis.call('GET', KEYS[3]) == ARGV[2] then return 0 end
redis.call('SADD', KEYS[2], ARGV[1])
for index = 4, #ARGV do
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[index])
end
return #ARGV - 3
`
const renewBatchScript = `
for index = 2, #ARGV, 2 do
local member = ARGV[index]
local token = ARGV[index + 1]
if redis.call('HGET', KEYS[2], member) ~= token then return 0 end
if redis.call('ZSCORE', KEYS[1], member) == false then return 0 end
end
for index = 2, #ARGV, 2 do
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[index])
end
return (#ARGV - 1) / 2
`
const ackBatchScript = `
local removed = 0
for index = 1, #ARGV, 2 do
local member = ARGV[index]
local token = ARGV[index + 1]
if redis.call('HGET', KEYS[2], member) == token then
redis.call('ZREM', KEYS[1], member)
redis.call('HDEL', KEYS[2], member)
removed = removed + 1
end
end
return removed
`
const blockRunScript = `
redis.call('SET', KEYS[3], ARGV[1])
local pending = 0
local members = redis.call('ZRANGE', KEYS[1], 0, -1)
for _, member in ipairs(members) do
-- Task members are JSON produced by this package. Match the complete
-- runId field so blocking run A cannot delete run B's work on the same
-- legacy channel queue.
if string.find(member, '"runId":"' .. ARGV[1] .. '"', 1, true) then
redis.call('ZREM', KEYS[1], member)
redis.call('HDEL', KEYS[2], member)
pending = pending + 1
end
end
return pending
`
+274
View File
@@ -0,0 +1,274 @@
package zset
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/container/gvar"
)
type redisCall struct {
command string
args []any
}
type fakeRedis struct {
calls []redisCall
responses []*gvar.Var
err error
}
func (f *fakeRedis) Do(_ context.Context, command string, args ...any) (*gvar.Var, error) {
f.calls = append(f.calls, redisCall{command: command, args: args})
if f.err != nil {
return nil, f.err
}
if len(f.responses) == 0 {
return gvar.New(int64(1)), nil
}
response := f.responses[0]
f.responses = f.responses[1:]
return response, nil
}
func (f *fakeRedis) Eval(_ context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
allArgs := []any{script, numKeys}
for _, k := range keys {
allArgs = append(allArgs, k)
}
allArgs = append(allArgs, args...)
f.calls = append(f.calls, redisCall{command: "EVAL", args: allArgs})
if f.err != nil {
return nil, f.err
}
if len(f.responses) == 0 {
return gvar.New(int64(1)), nil
}
response := f.responses[0]
f.responses = f.responses[1:]
return response, nil
}
func TestStoreUsesContractKeys(t *testing.T) {
store, err := New(&fakeRedis{}, "flow:")
if err != nil {
t.Fatal(err)
}
key, err := store.Key("mobile-a")
if err != nil {
t.Fatal(err)
}
if key != "flow:mobile-a" {
t.Fatalf("task key = %q, want flow:mobile-a", key)
}
lease, err := store.leaseKey("mobile-a")
if err != nil {
t.Fatal(err)
}
if lease != "flow:lease:mobile-a" {
t.Fatalf("lease key = %q, want flow:lease:mobile-a", lease)
}
if got := store.ChannelsKey(); got != "flow:channels" {
t.Fatalf("channels key = %q, want flow:channels", got)
}
}
func TestStableTaskID(t *testing.T) {
if got := StableTaskID("run-1", "current_month_usage", 42); got != "run-1:current_month_usage:42" {
t.Fatalf("stable task id = %q", got)
}
}
func TestRunKeysShareRedisHashTag(t *testing.T) {
store, err := New(&fakeRedis{}, "flow:")
if err != nil {
t.Fatal(err)
}
keys := []func() (string, error){
func() (string, error) { return store.RunMetaKey("run-1") },
func() (string, error) { return store.RoundActiveKey("run-1", 1, "mobile-a") },
func() (string, error) { return store.RoundWaitingKey("run-1", 2, "mobile-a") },
func() (string, error) { return store.RoundLeaseKey("run-1", 1, "mobile-a") },
func() (string, error) { return store.TaskStateKey("run-1") },
func() (string, error) { return store.FailedKey("run-1") },
}
for _, build := range keys {
key, err := build()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(key, "flow:{run-1}") {
t.Fatalf("run key %q does not contain hash tag", key)
}
}
}
func TestEnqueueMemberExcludesSchedulingScore(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(1))}}
store, err := New(fake, "flow:")
if err != nil {
t.Fatal(err)
}
task := Task{TaskID: "run:current_month_usage:1", ChanKey: "mobile-a", CardID: 1}
if err = store.Enqueue(context.Background(), task, time.UnixMilli(1234)); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v", fake.calls)
}
member, ok := fake.calls[0].args[len(fake.calls[0].args)-1].(string)
if !ok {
t.Fatalf("member type = %T", fake.calls[0].args[len(fake.calls[0].args)-1])
}
if strings.Contains(member, "nextExecuteAt") {
t.Fatalf("member contains volatile score: %s", member)
}
}
func TestClaimDecodesMemberAndUsesLeaseKey(t *testing.T) {
task := Task{
TaskID: "run-1:current_month_usage:42",
RunID: "run-1",
Operation: "current_month_usage",
QueryMonth: "202608",
ChanKey: "mobile-a",
CardID: 42,
ICCID: "8986000000000042",
}
member, err := json.Marshal(task)
if err != nil {
t.Fatal(err)
}
fake := &fakeRedis{responses: []*gvar.Var{gvar.New([]any{string(member), "worker:token"})}}
store, err := New(fake, "flow:")
if err != nil {
t.Fatal(err)
}
now := time.UnixMilli(1000)
leaseUntil := now.Add(time.Minute)
claimed, err := store.Claim(context.Background(), "mobile-a", now, leaseUntil, 1)
if err != nil {
t.Fatal(err)
}
if len(claimed) != 1 || claimed[0].Task.TaskID != task.TaskID {
t.Fatalf("claimed tasks = %+v", claimed)
}
if claimed[0].LeaseToken != "worker:token" {
t.Fatalf("lease token = %q", claimed[0].LeaseToken)
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v", fake.calls)
}
args := fake.calls[0].args
if len(args) < 4 || args[3] != "flow:lease:mobile-a" {
t.Fatalf("claim args do not use lease key: %+v", args)
}
}
func TestAckBatchUsesLuaOwnershipProtocol(t *testing.T) {
fake := &fakeRedis{}
store, err := New(fake, "flow:")
if err != nil {
t.Fatal(err)
}
claimed := ClaimedTask{
Task: Task{TaskID: "run:current_month_usage:1", ChanKey: "mobile-a", CardID: 1},
Member: `{"taskId":"run:current_month_usage:1","chanKey":"mobile-a","cardId":1}`,
LeaseToken: "token-1",
}
count, err := store.AckBatch(context.Background(), []ClaimedTask{claimed})
if err != nil || count != 1 {
t.Fatalf("ack count = %d, err = %v", count, err)
}
renewed, err := store.Renew(context.Background(), claimed, time.Now().Add(time.Minute))
if err != nil || !renewed {
t.Fatalf("renew = %t, err = %v", renewed, err)
}
if len(fake.calls) != 2 {
t.Fatalf("Redis calls = %d, want 2", len(fake.calls))
}
for _, call := range fake.calls {
if call.command != "EVAL" {
t.Fatalf("command = %q, want EVAL", call.command)
}
script, ok := call.args[0].(string)
if !ok || !strings.Contains(script, "HGET") {
t.Fatalf("Lua script does not validate ownership: %v", call.args[0])
}
}
}
func TestRenewBatchUsesOneAtomicLuaCall(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
store, err := New(fake, "flow:")
if err != nil {
t.Fatal(err)
}
tasks := []ClaimedTask{
{Task: Task{ChanKey: "mobile-a"}, Member: "member-1", LeaseToken: "token-1"},
{Task: Task{ChanKey: "mobile-a"}, Member: "member-2", LeaseToken: "token-2"},
}
count, err := store.RenewBatch(context.Background(), tasks, time.UnixMilli(5000))
if err != nil || count != 2 {
t.Fatalf("RenewBatch() = %d, err = %v", count, err)
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v, want one EVAL", fake.calls)
}
script := fake.calls[0].args[0].(string)
if !strings.Contains(script, "HGET") || !strings.Contains(script, "ZSCORE") {
t.Fatalf("renew script does not validate all leases: %s", script)
}
}
func TestEnqueueBatchRegistersChannelAndWritesTasksOnce(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
store, err := New(fake, "flow:")
if err != nil {
t.Fatal(err)
}
tasks := []Task{
{TaskID: "run:usage:1", RunID: "run", ChanKey: "mobile-a", CardID: 1},
{TaskID: "run:usage:2", RunID: "run", ChanKey: "mobile-a", CardID: 2},
}
added, err := store.EnqueueBatch(context.Background(), "mobile-a", tasks, time.UnixMilli(1234))
if err != nil || added != 2 {
t.Fatalf("EnqueueBatch() = %d, err = %v", added, err)
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v, want one EVAL", fake.calls)
}
if fake.calls[0].args[2] != "flow:mobile-a" || fake.calls[0].args[3] != "flow:channels" || fake.calls[0].args[4] != "flow:blocked:mobile-a" {
t.Fatalf("batch keys = %+v", fake.calls[0].args)
}
}
func TestEnqueueBatchRejectsMixedChannels(t *testing.T) {
store, err := New(&fakeRedis{}, "flow:")
if err != nil {
t.Fatal(err)
}
_, err = store.EnqueueBatch(context.Background(), "mobile-a", []Task{{TaskID: "1", RunID: "run", ChanKey: "mobile-b"}}, time.Now())
if err == nil {
t.Fatal("expected mixed channel batch error")
}
}
func TestBlockRunUsesTaskLeaseAndBlockedKeys(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(7))}}
store, err := New(fake, "flow:")
if err != nil {
t.Fatal(err)
}
removed, err := store.BlockRun(context.Background(), "telecom-a", "run-1")
if err != nil || removed != 7 {
t.Fatalf("BlockRun() = %d, err = %v", removed, err)
}
args := fake.calls[0].args
if args[2] != "flow:telecom-a" || args[3] != "flow:lease:telecom-a" || args[4] != "flow:blocked:telecom-a" {
t.Fatalf("block keys = %+v", args)
}
}
+100
View File
@@ -0,0 +1,100 @@
package zset
import (
"context"
"encoding/json"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
"time"
)
// ReadSupply uses only queue cardinalities, never task bodies or task-state scans.
// All keys in a batch share the run hash slot. The returned round is verified
// inside the script so a round transition cannot mix queue generations.
func (s *Store) ReadSupply(ctx context.Context, runID string, channels []string, now time.Time) (map[string]supply.Sample, error) {
out := make(map[string]supply.Sample, len(channels))
meta, err := s.RunMetaKey(runID)
if err != nil {
return nil, err
}
for offset := 0; offset < len(channels); offset += 64 {
batch := channels[offset:min(offset+64, len(channels))]
keys := []string{meta}
args := []any{now.UnixMilli()}
for _, ch := range batch {
active, e := s.RoundActiveKey(runID, 1, ch)
if e != nil {
return nil, e
}
lease, e := s.RoundLeaseKey(runID, 1, ch)
if e != nil {
return nil, e
}
keys = append(keys, active, lease)
args = append(args, ch)
}
result, e := s.redis.Eval(ctx, readSupplyScript, int64(len(keys)), keys, args)
if e != nil {
return nil, e
}
var values [][]int64
if e = json.Unmarshal([]byte(result.String()), &values); e != nil {
return nil, e
}
if len(values) != len(batch) {
return nil, fmt.Errorf("incomplete supply response")
}
for i, ch := range batch {
v := values[i]
if len(v) != 5 {
return nil, fmt.Errorf("invalid supply response")
}
out[ch] = supply.Sample{Known: v[0] == 1, Eligible: v[1] == 1, Ready: v[2], Leases: v[3], Future: v[4]}
}
}
return out, nil
}
const readSupplyScript = `
local run=redis.call('HMGET',KEYS[1],'runId','status','currentRound','producerDone','syncType')
local known=run[1] and run[2] and run[3] and run[4]
local eligible=known and (run[2]=='producing' or run[2]=='running') and run[3]=='1' and run[4]=='0' and run[5]~='replay'
local out={}
for i=2,#KEYS,2 do
local ch=ARGV[(i+2)/2]
local blocked=redis.call('HGET',KEYS[1],'blocked:'..ch)
table.insert(out,{known and 1 or 0,(eligible and blocked~='1') and 1 or 0,redis.call('ZCOUNT',KEYS[i],'-inf',ARGV[1]),redis.call('ZCARD',KEYS[i+1]),redis.call('ZCOUNT',KEYS[i],'('..ARGV[1],'+inf')})
end
return cjson.encode(out)
`
// SaveSupply requires owner validation in the same Redis transaction as the
// write. The existing producer lock is global; this matches the standalone
// Redis deployment and deliberately fails closed on a cluster CROSSSLOT error.
func (s *Store) SaveSupply(ctx context.Context, runID, lockKey, owner string, snapshot []byte) (bool, error) {
if len(snapshot) > 256*1024 {
return false, fmt.Errorf("supply snapshot exceeds 256 KiB")
}
meta, err := s.RunMetaKey(runID)
if err != nil {
return false, err
}
result, err := s.redis.Eval(ctx, saveSupplyScript, 2, []string{meta, lockKey}, []any{owner, string(snapshot)})
if err != nil {
return false, err
}
return result.Int() == 1, nil
}
const saveSupplyScript = `
local current=redis.call('GET',KEYS[2])
if not current then return 0 end
local ok,decoded=pcall(cjson.decode,current)
local owner=current
if ok and type(decoded)=='table' then owner=decoded['owner'] end
if owner~=ARGV[1] then return 0 end
local status=redis.call('HGET',KEYS[1],'status')
if status~='producing' and status~='running' then return 0 end
redis.call('HSET',KEYS[1],'supplyObservation',ARGV[2])
return 1
`
@@ -0,0 +1,78 @@
package zset
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
"testing"
"time"
)
func TestSupplyBatchAndLeaseView(t *testing.T) {
s := newLockStore(t)
ctx := context.Background()
run := "supply-test"
if err := s.CreateRun(ctx, RunMeta{RunID: run, Status: "producing", CurrentRound: 1}); err != nil {
t.Fatal(err)
}
keys := make([]string, 130)
now := time.Now()
for i := range keys {
keys[i] = fmt.Sprintf("channel-%03d", i)
}
a, _ := s.RoundActiveKey(run, 1, keys[0])
l, _ := s.RoundLeaseKey(run, 1, keys[1])
if _, err := s.redis.Do(ctx, "ZADD", a, now.UnixMilli(), "ready", now.Add(time.Hour).UnixMilli(), "future"); err != nil {
t.Fatal(err)
}
if _, err := s.redis.Do(ctx, "ZADD", l, now.Add(-time.Hour).UnixMilli(), "expired"); err != nil {
t.Fatal(err)
}
view, err := s.ReadSupply(ctx, run, keys, now)
if err != nil {
t.Fatal(err)
}
if len(view) != 130 || view[keys[0]].Ready != 1 || view[keys[1]].Leases != 1 || !view[keys[129]].Eligible {
t.Fatalf("bad view %+v", view)
}
meta, _ := s.RunMetaKey(run)
s.redis.Do(ctx, "HSET", meta, "blocked:"+keys[2], 1, "currentRound", 2)
view, err = s.ReadSupply(ctx, run, keys, now)
if err != nil {
t.Fatal(err)
}
for _, v := range view {
if v.Eligible {
t.Fatal("retry round eligible")
}
}
}
func TestSupplyOwnerAndTerminalGuard(t *testing.T) {
s := newLockStore(t)
ctx := context.Background()
run := "supply-guard"
s.CreateRun(ctx, RunMeta{RunID: run, Status: "producing", CurrentRound: 1})
lock := "flow:producer:active"
s.AcquireLockOrTakeover(ctx, lock, "old", run, time.Minute)
encoded, _ := supply.New("old", time.Now(), []string{"a"}).Encode()
ok, err := s.SaveSupply(ctx, run, lock, "old", encoded)
if err != nil || !ok {
t.Fatalf("save %v %v", ok, err)
}
s.AcquireLockOrTakeover(ctx, lock, "new", run, time.Minute)
ok, err = s.SaveSupply(ctx, run, lock, "old", encoded)
if err != nil || ok {
t.Fatalf("old owner save %v %v", ok, err)
}
meta, _ := s.RunMetaKey(run)
s.redis.Do(ctx, "HSET", meta, "status", "completed")
ok, err = s.SaveSupply(ctx, run, lock, "new", encoded)
if err != nil || ok {
t.Fatalf("terminal save %v %v", ok, err)
}
s.redis.Do(ctx, "HSET", meta, "supplyObservation", "{broken")
m, err := s.GetRunMeta(ctx, run)
if err != nil || m.SupplyObservation == nil || !m.SupplyObservation.Partial {
t.Fatalf("invalid telemetry poisoned business %+v %v", m, err)
}
}
@@ -0,0 +1,50 @@
package zset
import "testing"
// TestFailureRecordUsesTelecomTemplateVersion covers the telecom branch and the
// generic fallback of the carrier label written into a FailureRecord.
func TestFailureRecordUsesTelecomTemplateVersion(t *testing.T) {
for _, key := range []string{"sim_dx_v1", " SIM_DX_V3 "} {
if got := failureFromOutcome(Task{TempKey: key}, TaskOutcome{}, 1); got.Carrier != "telecom" {
t.Fatalf("%q carrier = %q", key, got.Carrier)
}
}
if got := failureFromOutcome(Task{TempKey: "sim_dx_v2_future"}, TaskOutcome{}, 1); got.Carrier != "generic" {
t.Fatalf("unknown carrier = %q", got.Carrier)
}
}
// TestFailureRecordUsesMobileTemplateSet pins the mobile template list. The list
// must stay in sync with carrierMobile's set in
// internal/consumer/zset/consumer.go: a template missing here is silently
// labelled generic, so failure review and replay would treat a real mobile
// channel as the slow generic pool.
//
// lanshang_8000 / lanshang_8003 are the regression this test was added for --
// they are OneLink shared-token templates whose names omit the sim_onelink_
// prefix, so they were omitted from both the QPS policy and this label.
func TestFailureRecordUsesMobileTemplateSet(t *testing.T) {
mobileKeys := []string{
"sim_yd_v5",
"sim_yd_mock_v5",
"sim_onelink_tengyu_v1",
"sim_onelink_xuzhou_mobile_v1",
"sim_onelink_lelian_v1",
"sim_onelink_beixin_v1",
"sim_onelink_qianxun_v1",
"sim_onelink_henan_xinjing_v1",
"sim_onelink_daihe_v1",
"lanshang_8000",
"lanshang_8003",
}
for _, key := range mobileKeys {
if got := failureFromOutcome(Task{TempKey: key}, TaskOutcome{}, 1); got.Carrier != "mobile" {
t.Fatalf("%q carrier = %q, want mobile", key, got.Carrier)
}
}
// Case and padding must not change the classification.
if got := failureFromOutcome(Task{TempKey: " LANSHANG_8000 "}, TaskOutcome{}, 1); got.Carrier != "mobile" {
t.Fatalf("padded lanshang_8000 carrier = %q, want mobile", got.Carrier)
}
}