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:
@@ -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
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user