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,474 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
)
|
||||
|
||||
// SimpleReplay hands every source failure to a separate Run. The source hash
|
||||
// is the checkpoint: a record is removed only after the target's task-state
|
||||
// confirms that EnqueueRunBatch accepted it. No cursor is retained across
|
||||
// mutations of the source hash.
|
||||
//
|
||||
// The target's retry-round budget is taken from the caller when supplied and
|
||||
// falls back to the configured default otherwise. It is persisted on the
|
||||
// source reservation because a worker restart re-enters through reconcile(),
|
||||
// which no longer has the caller's arguments.
|
||||
type SimpleReplay struct {
|
||||
stores []*zset.Store
|
||||
redis zset.RedisClient
|
||||
// defaultMaxRounds applies when a replay request does not specify one.
|
||||
defaultMaxRounds int
|
||||
}
|
||||
|
||||
// Replay round bounds. MaxReplayRounds is a server-side guard, not a capacity
|
||||
// limit: the observed chain of 10-09 needed 74 rounds to reach 98% recovery,
|
||||
// so a low ceiling would cut off a legitimate convergence path.
|
||||
//
|
||||
// DefaultReplayMaxRounds is only the fallback for a request that omits a
|
||||
// budget; callers may ask for more, up to MaxReplayRounds.
|
||||
const (
|
||||
DefaultReplayMaxRounds = 3
|
||||
MaxReplayRounds = 100
|
||||
legacyReplayMaxRounds = 1
|
||||
)
|
||||
|
||||
type ReplayReceipt struct {
|
||||
ReplayRunID string `json:"replayRunId"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ReplayStatus struct {
|
||||
ReplayRunID string `json:"replayRunId"`
|
||||
SourceRunID string `json:"sourceRunId"`
|
||||
Status string `json:"status"`
|
||||
Remaining int64 `json:"remaining"`
|
||||
Enqueued int64 `json:"enqueued"`
|
||||
RunStatus string `json:"runStatus,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var ErrNoFailures = errors.New("run has no failures to replay")
|
||||
|
||||
func NewSimpleReplay(stores ...*zset.Store) (*SimpleReplay, error) {
|
||||
return NewSimpleReplayWithDefault(DefaultReplayMaxRounds, stores...)
|
||||
}
|
||||
|
||||
// NewSimpleReplayWithDefault builds a replay service whose unspecified replay
|
||||
// requests inherit defaultMaxRounds. A non-positive value falls back to
|
||||
// DefaultReplayMaxRounds so a missing config key cannot disable the budget.
|
||||
func NewSimpleReplayWithDefault(defaultMaxRounds int, stores ...*zset.Store) (*SimpleReplay, error) {
|
||||
if len(stores) == 0 || stores[0] == nil {
|
||||
return nil, fmt.Errorf("replay requires Redis stores")
|
||||
}
|
||||
for _, store := range stores {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("replay store is nil")
|
||||
}
|
||||
}
|
||||
if defaultMaxRounds <= 0 {
|
||||
defaultMaxRounds = DefaultReplayMaxRounds
|
||||
}
|
||||
if defaultMaxRounds > MaxReplayRounds {
|
||||
defaultMaxRounds = MaxReplayRounds
|
||||
}
|
||||
return &SimpleReplay{stores: stores, redis: stores[0].RedisClient(), defaultMaxRounds: defaultMaxRounds}, nil
|
||||
}
|
||||
|
||||
func (r *SimpleReplay) find(ctx context.Context, id string) (*zset.Store, zset.RunMeta, error) {
|
||||
if err := contract.ValidateID(id); err != nil || len(id) > 128 {
|
||||
return nil, zset.RunMeta{}, contract.ErrInvalidArgument
|
||||
}
|
||||
var found *zset.Store
|
||||
var meta zset.RunMeta
|
||||
for _, store := range r.stores {
|
||||
key, _ := store.RunMetaKey(id)
|
||||
v, err := r.redis.Do(ctx, "EXISTS", key)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
if v.Int() == 0 {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return nil, meta, contract.ErrConflict
|
||||
}
|
||||
found = store
|
||||
meta, err = store.GetRunMeta(ctx, id)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return nil, meta, contract.ErrNotFound
|
||||
}
|
||||
return found, meta, nil
|
||||
}
|
||||
|
||||
// Request accepts a replay of the source run's failures. maxRounds == 0 means
|
||||
// "use the configured default"; requests above MaxReplayRounds are rejected.
|
||||
func (r *SimpleReplay) Request(ctx context.Context, sourceID, requestedBy string, maxRounds int) (ReplayReceipt, error) {
|
||||
if len(requestedBy) == 0 || len(requestedBy) > 64 {
|
||||
return ReplayReceipt{}, contract.ErrInvalidArgument
|
||||
}
|
||||
if maxRounds < 0 || maxRounds > MaxReplayRounds {
|
||||
return ReplayReceipt{}, contract.ErrInvalidArgument
|
||||
}
|
||||
if maxRounds == 0 {
|
||||
maxRounds = r.defaultMaxRounds
|
||||
}
|
||||
store, _, err := r.find(ctx, sourceID)
|
||||
if err != nil {
|
||||
return ReplayReceipt{}, err
|
||||
}
|
||||
metaKey, _ := store.RunMetaKey(sourceID)
|
||||
failedKey, _ := store.FailedKey(sourceID)
|
||||
var random [16]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return ReplayReceipt{}, err
|
||||
}
|
||||
targetID := "replay-" + hex.EncodeToString(random[:])
|
||||
// An index written before the reservation makes the pending source
|
||||
// discoverable even if the process stops immediately after the Lua call.
|
||||
if _, err = r.redis.Do(ctx, "SADD", store.Prefix()+"replay:pending", sourceID); err != nil {
|
||||
return ReplayReceipt{}, err
|
||||
}
|
||||
v, err := r.redis.Eval(ctx, reserveReplay, 2, []string{metaKey, failedKey}, []any{targetID, requestedBy, maxRounds})
|
||||
if err != nil {
|
||||
return ReplayReceipt{}, err
|
||||
}
|
||||
result := v.String()
|
||||
// An unsuccessful reservation leaves an inert discovery entry. Never
|
||||
// remove it here: a concurrent caller might have reserved the source
|
||||
// after this script returned, and that reservation needs recovery.
|
||||
switch result {
|
||||
case "missing":
|
||||
return ReplayReceipt{}, contract.ErrNotFound
|
||||
case "running":
|
||||
return ReplayReceipt{}, contract.ErrConflict
|
||||
case "empty":
|
||||
return ReplayReceipt{}, ErrNoFailures
|
||||
}
|
||||
if !strings.HasPrefix(result, "replay-") {
|
||||
return ReplayReceipt{}, fmt.Errorf("unexpected replay reservation result")
|
||||
}
|
||||
go r.process(store, sourceID)
|
||||
return ReplayReceipt{ReplayRunID: result, Status: "accepted"}, nil
|
||||
}
|
||||
|
||||
// Start reconciles pending sources after restarts and missed goroutine wakes.
|
||||
// Every process may run it: the per-source Redis lease elects one writer.
|
||||
func (r *SimpleReplay) Start(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
r.reconcile(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *SimpleReplay) reconcile(ctx context.Context) {
|
||||
for _, store := range r.stores {
|
||||
v, err := r.redis.Do(ctx, "SMEMBERS", store.Prefix()+"replay:pending")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, id := range v.Strings() {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
key, keyErr := store.RunMetaKey(id)
|
||||
if keyErr != nil {
|
||||
continue
|
||||
}
|
||||
reservation, getErr := r.redis.Do(ctx, "HGET", key, "replayTargetId")
|
||||
// SADD happens before the reservation so even a crash during
|
||||
// acceptance cannot lose work. Unreserved entries are inert.
|
||||
if getErr != nil || reservation == nil || reservation.String() == "" {
|
||||
continue
|
||||
}
|
||||
state, stateErr := r.redis.Do(ctx, "HGET", key, "replayState")
|
||||
if stateErr != nil || state == nil || state.String() == "needs_attention" {
|
||||
continue
|
||||
}
|
||||
go r.process(store, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SimpleReplay) Status(ctx context.Context, targetID string) (ReplayStatus, error) {
|
||||
store, target, err := r.find(ctx, targetID)
|
||||
if err != nil {
|
||||
// The target may not exist yet: locate its durable source reservation.
|
||||
if !errors.Is(err, contract.ErrNotFound) {
|
||||
return ReplayStatus{}, err
|
||||
}
|
||||
for _, candidate := range r.stores {
|
||||
v, e := r.redis.Do(ctx, "SMEMBERS", candidate.Prefix()+"replay:pending")
|
||||
if e != nil {
|
||||
return ReplayStatus{}, e
|
||||
}
|
||||
for _, sourceID := range v.Strings() {
|
||||
key, _ := candidate.RunMetaKey(sourceID)
|
||||
value, e := r.redis.Do(ctx, "HGET", key, "replayTargetId")
|
||||
if e == nil && value != nil && value.String() == targetID {
|
||||
store = candidate
|
||||
target.SourceRunID = sourceID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if store == nil {
|
||||
return ReplayStatus{}, contract.ErrNotFound
|
||||
}
|
||||
}
|
||||
if target.SourceRunID == "" {
|
||||
return ReplayStatus{}, contract.ErrNotFound
|
||||
}
|
||||
sourceKey, _ := store.RunMetaKey(target.SourceRunID)
|
||||
failureKey, _ := store.FailedKey(target.SourceRunID)
|
||||
values, err := r.redis.Do(ctx, "HMGET", sourceKey, "replayTargetId", "replayState", "replayError", "replayEnqueued")
|
||||
if err != nil {
|
||||
return ReplayStatus{}, err
|
||||
}
|
||||
fields := values.Vars()
|
||||
if len(fields) != 4 || fields[0].String() != targetID {
|
||||
return ReplayStatus{}, contract.ErrNotFound
|
||||
}
|
||||
remaining, err := r.redis.Do(ctx, "HLEN", failureKey)
|
||||
if err != nil {
|
||||
return ReplayStatus{}, err
|
||||
}
|
||||
return ReplayStatus{ReplayRunID: targetID, SourceRunID: target.SourceRunID, Status: fields[1].String(), Error: fields[2].String(), Enqueued: fields[3].Int64(), Remaining: remaining.Int64(), RunStatus: target.Status}, nil
|
||||
}
|
||||
|
||||
func (r *SimpleReplay) process(store *zset.Store, sourceID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
// Keep one process per source; batches are bounded so a short lease is
|
||||
// renewed before it expires. A lost lease stops cleanup at the next batch.
|
||||
lockKey := store.Prefix() + "{" + sourceID + "}:replay-lock"
|
||||
var token [16]byte
|
||||
if _, err := rand.Read(token[:]); err != nil {
|
||||
return
|
||||
}
|
||||
owner := hex.EncodeToString(token[:])
|
||||
locked, err := store.AcquireLock(ctx, lockKey, owner, time.Minute)
|
||||
if err != nil || !locked {
|
||||
return
|
||||
}
|
||||
defer func() { _, _ = store.ReleaseLock(context.Background(), lockKey, owner) }()
|
||||
if err := r.handoff(ctx, store, sourceID, lockKey, owner); err != nil {
|
||||
// The time slice ended, not the replay: the reconciler will resume
|
||||
// from remaining source records under a new lease.
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
sourceKey, _ := store.RunMetaKey(sourceID)
|
||||
// A stale owner must not overwrite a newer worker's status.
|
||||
_, _ = r.redis.Eval(context.Background(), replayAttention, 2, []string{sourceKey, lockKey}, []any{owner})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SimpleReplay) handoff(ctx context.Context, store *zset.Store, sourceID, lockKey, owner string) error {
|
||||
sourceKey, _ := store.RunMetaKey(sourceID)
|
||||
failedKey, _ := store.FailedKey(sourceID)
|
||||
meta, err := store.GetRunMeta(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := r.redis.Do(ctx, "HGET", sourceKey, "replayTargetId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v == nil || v.String() == "" {
|
||||
return fmt.Errorf("replay reservation missing")
|
||||
}
|
||||
targetID := v.String()
|
||||
// New reservations persist their budget. Reservations made before this
|
||||
// field existed used one round; a restart must not apply today's default
|
||||
// to those already accepted runs.
|
||||
storedMaxRounds, err := r.redis.Do(ctx, "HGET", sourceKey, "replayMaxRound")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxRounds := legacyReplayMaxRounds
|
||||
if storedMaxRounds != nil && storedMaxRounds.String() != "" {
|
||||
maxRounds = storedMaxRounds.Int()
|
||||
}
|
||||
if maxRounds <= 0 {
|
||||
return fmt.Errorf("replay reservation has invalid round budget")
|
||||
}
|
||||
if maxRounds > MaxReplayRounds {
|
||||
return fmt.Errorf("replay reservation exceeds round budget limit")
|
||||
}
|
||||
// CreateRun is idempotent for the main hash but writes extension fields
|
||||
// again. Read and validate an existing target BEFORE CreateRun, otherwise
|
||||
// recovery could silently replace its accepted maxRound.
|
||||
existing, err := store.GetRunMeta(ctx, targetID)
|
||||
if err != nil && !errors.Is(err, zset.ErrRunNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
if storedMaxRounds == nil || storedMaxRounds.String() == "" {
|
||||
maxRounds = existing.MaxRound
|
||||
}
|
||||
if maxRounds <= 0 || maxRounds > MaxReplayRounds || existing.SourceRunID != sourceID || existing.SyncType != "replay" || existing.MaxRound != maxRounds || existing.Operation != meta.Operation || existing.QueryMonth != meta.QueryMonth {
|
||||
return fmt.Errorf("replay target metadata is inconsistent")
|
||||
}
|
||||
}
|
||||
target := zset.RunMeta{RunID: targetID, Operation: meta.Operation, QueryMonth: meta.QueryMonth, SyncType: "replay", SourceRunID: sourceID, ReplayCycle: meta.ReplayCycle + 1, TriggerSource: "replay", MaxRound: maxRounds}
|
||||
if err := store.CreateRun(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
actual, err := store.GetRunMeta(ctx, targetID)
|
||||
if err != nil || actual.SourceRunID != sourceID || actual.SyncType != "replay" || actual.MaxRound != maxRounds || actual.Operation != meta.Operation || actual.QueryMonth != meta.QueryMonth {
|
||||
return fmt.Errorf("replay target metadata is inconsistent")
|
||||
}
|
||||
// The target task-state is the admission receipt during recovery. It may
|
||||
// not expire while source failures are still being transferred.
|
||||
targetKey, _ := store.RunMetaKey(targetID)
|
||||
stateKey, _ := store.TaskStateKey(targetID)
|
||||
if !actual.ProducerDone {
|
||||
if _, err := r.redis.Do(ctx, "PERSIST", targetKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.redis.Do(ctx, "PERSIST", stateKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = r.redis.Do(ctx, "HSET", sourceKey, "replayState", "processing", "replayError", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
valid, err := store.RenewLock(ctx, lockKey, owner, time.Minute)
|
||||
if err != nil || !valid {
|
||||
return fmt.Errorf("replay lease lost: %w", err)
|
||||
}
|
||||
// HSCAN is not a snapshot. Complete a bounded cursor pass, then
|
||||
// recheck HLEN and repeat from zero if deletion changed the scan.
|
||||
var cursor uint64
|
||||
for {
|
||||
v, e := r.redis.Do(ctx, "HSCAN", failedKey, cursor, "COUNT", 100)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var scanPairs []*gvar.Var
|
||||
cursor, scanPairs, e = parseScanResult(v)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(scanPairs) > 0 {
|
||||
for i := 0; i < len(scanPairs); i += 2 {
|
||||
valid, renewErr := store.RenewLock(ctx, lockKey, owner, time.Minute)
|
||||
if renewErr != nil || !valid {
|
||||
return fmt.Errorf("replay lease lost")
|
||||
}
|
||||
field, raw := scanPairs[i].String(), scanPairs[i+1].String()
|
||||
var rec zset.FailureRecord
|
||||
if json.Unmarshal([]byte(raw), &rec) != nil || rec.TaskID == "" || rec.CardID <= 0 || rec.ICCID == "" || rec.ChanKey == "" || rec.TempKey == "" || field != rec.TaskID || (rec.LatestRunID != "" && rec.LatestRunID != sourceID) {
|
||||
return fmt.Errorf("source failure record cannot be replayed")
|
||||
}
|
||||
operation := rec.Operation
|
||||
if operation == "" {
|
||||
operation = meta.Operation
|
||||
}
|
||||
// Source task identity (not just card ID) prevents collisions
|
||||
// between distinct failures for the same card.
|
||||
taskID := targetID + ":" + rec.TaskID
|
||||
queryMonth := rec.QueryMonth
|
||||
if queryMonth == "" {
|
||||
queryMonth = meta.QueryMonth
|
||||
}
|
||||
task := zset.Task{RunID: targetID, TaskID: taskID, Operation: operation, QueryMonth: queryMonth, CardID: rec.CardID, ICCID: rec.ICCID, MSISDN: rec.MSISDN, IMSI: rec.IMSI, ChanKey: rec.ChanKey, TempKey: rec.TempKey, PackageCode: rec.PackageCode}
|
||||
if _, e = store.EnqueueRunBatch(ctx, targetID, rec.ChanKey, []zset.Task{task}, time.Now()); e != nil {
|
||||
return e
|
||||
}
|
||||
confirmed, e := r.redis.Do(ctx, "HEXISTS", stateKey, taskID)
|
||||
if e != nil || confirmed == nil || confirmed.Int() != 1 {
|
||||
return fmt.Errorf("target enqueue unconfirmed: %w", e)
|
||||
}
|
||||
removed, e := r.redis.Eval(ctx, cleanupReplayFailure, 3, []string{sourceKey, failedKey, lockKey}, []any{field, raw, targetID, owner})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if removed.Int() == -1 {
|
||||
return fmt.Errorf("source failure changed during replay")
|
||||
}
|
||||
}
|
||||
}
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
remaining, err := r.redis.Do(ctx, "HLEN", failedKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining.Int64() == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Persist producerDone only after every source record was confirmed.
|
||||
if err := store.MarkProducerDone(ctx, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.redis.Do(ctx, "HSET", sourceKey, "replayState", "completed", "replayError", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
// The source summary has already been archived. Keep the small source
|
||||
// metadata for lookup but restore a bounded retention after handoff.
|
||||
if _, err := r.redis.Do(ctx, "EXPIRE", sourceKey, 30*24*3600); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.redis.Do(ctx, "SREM", store.Prefix()+"replay:pending", sourceID)
|
||||
return err
|
||||
}
|
||||
|
||||
const replayAttention = `
|
||||
if redis.call('GET',KEYS[2])~=ARGV[1] then return 0 end
|
||||
redis.call('HSET',KEYS[1],'replayState','needs_attention','replayError','Replay handoff stopped; check Worker logs')
|
||||
return 1
|
||||
`
|
||||
|
||||
const reserveReplay = `
|
||||
local id=redis.call('HGET',KEYS[1],'replayTargetId')
|
||||
if id then return id end
|
||||
local status=redis.call('HGET',KEYS[1],'status')
|
||||
if not status then return 'missing' end
|
||||
if status~='completed' and status~='completed_with_failures' then return 'running' end
|
||||
if redis.call('HLEN',KEYS[2])==0 then return 'empty' end
|
||||
redis.call('HSET',KEYS[1],'replayTargetId',ARGV[1],'replayState','accepted','replayRequestedBy',ARGV[2],'replayEnqueued',0,'replayMaxRound',ARGV[3])
|
||||
redis.call('PERSIST',KEYS[1]); redis.call('PERSIST',KEYS[2])
|
||||
return ARGV[1]
|
||||
`
|
||||
const cleanupReplayFailure = `
|
||||
if redis.call('HGET',KEYS[1],'replayTargetId')~=ARGV[3] or redis.call('GET',KEYS[3])~=ARGV[4] then return -1 end
|
||||
local value=redis.call('HGET',KEYS[2],ARGV[1])
|
||||
if not value then return 0 end
|
||||
if value~=ARGV[2] then return -1 end
|
||||
redis.call('HDEL',KEYS[2],ARGV[1]); redis.call('HINCRBY',KEYS[1],'replayEnqueued',1)
|
||||
redis.call('HSET',KEYS[1],'pendingFailureCount',redis.call('HLEN',KEYS[2]))
|
||||
return 1
|
||||
`
|
||||
Reference in New Issue
Block a user