Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cc3666c
feat(evaluation): retry extension
lsy357 Feb 6, 2026
e290ac1
feat(evaluation): retry gen
lsy357 Feb 6, 2026
a28c726
feat(evaluation): retry extension
lsy357 Feb 9, 2026
6c7050f
Merge branch 'main' into feat/evalexpt
lsy357 Feb 9, 2026
7975d8b
fix(evaluation): event mode
lsy357 Feb 10, 2026
00e1b09
fix(evaluation): retryall
lsy357 Feb 12, 2026
08a4c53
fix(evaluation): retry
lsy357 Feb 12, 2026
4d4dd2f
Merge branch 'main' into feat/evalexpt
lsy357 Feb 12, 2026
0172752
fix(evaluation): custom raw err
lsy357 Feb 12, 2026
aa2fe89
fix(evaluation): errmsg
lsy357 Feb 13, 2026
63b9578
fix(evaluation): existed turn result
lsy357 Feb 13, 2026
014bdb7
fix(evaluation): scanitems
lsy357 Feb 14, 2026
cacd996
fix(evaluation): scanitems
lsy357 Feb 14, 2026
1842637
fix(evaluation): scancond
lsy357 Feb 14, 2026
1123075
fix(evaluation): gorm sql
lsy357 Feb 14, 2026
f2fc240
feat(evaluation): itemretry
lsy357 Feb 25, 2026
dfc48ea
fix(evaluation): ut
lsy357 Feb 26, 2026
c12983a
fix(evaluation): ut
lsy357 Feb 26, 2026
68b195f
fix(evaluation): experiment template itemretrynum
lsy357 Feb 26, 2026
0e012df
fix(evaluation): runid
lsy357 Feb 26, 2026
0c61ec5
fix(evaluation): appenditemids panic
lsy357 Feb 26, 2026
5e07bad
fix(evaluation): unlock
lsy357 Feb 26, 2026
c6c71eb
fix(evaluation): completing lock
lsy357 Feb 26, 2026
ffff714
fix(evaluation): retryitems epxtend
lsy357 Feb 26, 2026
0e73f1f
fix(evaluation): evaluator reenter
lsy357 Feb 26, 2026
6ded8f8
fix(evaluation): expt event expiration
lsy357 Feb 26, 2026
9b293f0
Merge branch 'main' into feat/evalexpt
lsy357 Feb 26, 2026
7f838b4
fix(evaluation): mockgen
lsy357 Feb 26, 2026
b1f2846
fix(evaluation): ut
lsy357 Feb 27, 2026
0cfafd5
fix(evaluation): IgnoreExistedResult
lsy357 Feb 27, 2026
11fd029
fix(evaluation): itemretrynum
lsy357 Feb 27, 2026
e3b1e83
Merge branch 'main' into feat/evalexpt
lsy357 Feb 27, 2026
474e7e1
fix(evaluation): ListEvaluationSetItems return
lsy357 Feb 27, 2026
3b56e59
fix(evaluation): ut
lsy357 Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAx
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[No Risk] No issues found with this dependency change.

The added checksum for github.com/google/subcommands v1.2.0 is consistent with the existing go.mod entry and does not introduce any obvious risks for the backend. Version choice and scope of usage look reasonable for CLI-style helpers.

github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
Expand Down
92 changes: 92 additions & 0 deletions backend/infra/lock/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ type ILocker interface {
// 后退出续期。调用方做写操作前应检查 ctx.Done 以确认仍持有锁,发生错误时应调用 cancel 以主动释放锁。
LockBackoffWithRenew(parent context.Context, key string, ttl time.Duration, maxHold time.Duration) (locked bool, ctx context.Context, cancel func(), err error)
LockWithRenew(parent context.Context, key string, ttl time.Duration, maxHold time.Duration) (locked bool, ctx context.Context, cancel func(), err error)

BackoffLockWithValue(ctx context.Context, key, val string, expiresIn time.Duration, backoff time.Duration) (bool, string, error)
UnlockWithValue(ctx context.Context, key, val string) (bool, error)
// UnlockForce deletes the key without comparing its value.
UnlockForce(ctx context.Context, key string) (bool, error)
// Exists returns true if the key exists.
Exists(ctx context.Context, key string) (bool, error)
}

func NewRedisLocker(c redis.Cmdable) ILocker {
Expand Down Expand Up @@ -143,6 +150,35 @@ func (r *redisLocker) Unlock(key string) (bool, error) {
return rt == 1, nil
}

func (r *redisLocker) UnlockWithValue(ctx context.Context, key, val string) (bool, error) {
const unlockWithValueScript = `if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[1]); return 1; end; return 0;`
result, err := r.c.Eval(ctx, unlockWithValueScript, []string{key}, val).Result()
if err != nil {
return false, errors.WithMessage(err, "unlock with lua script")
}
rt, ok := result.(int64)
if !ok {
return false, errors.Errorf("unknown result type %T", result)
}
return rt == 1, nil
}

func (r *redisLocker) UnlockForce(ctx context.Context, key string) (bool, error) {
n, err := r.c.Del(ctx, key).Result()
if err != nil {
return false, errors.WithMessage(err, "unlock force del")
}
return n > 0, nil
}

func (r *redisLocker) Exists(ctx context.Context, key string) (bool, error) {
n, err := r.c.Exists(ctx, key).Result()
if err != nil {
return false, errors.WithMessage(err, "exists")
}
return n > 0, nil
}

func (r *redisLocker) renewLock(ctx context.Context, key string, ttl time.Duration, maxHold time.Duration) {
t1 := time.After(maxHold)
t2 := time.NewTicker(gvalue.Max(time.Second, ttl>>2))
Expand Down Expand Up @@ -203,3 +239,59 @@ func (r *redisLocker) ExpireLockIn(key string, expiresIn time.Duration) (bool, e
}
return rt == 1, nil
}

const setNXWithGetScript = `
local ok = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])
if ok then
return {1, ARGV[1]}
else
local cur = redis.call('GET', KEYS[1])
return {0, cur or ''}
end
`

func (r *redisLocker) BackoffLockWithValue(ctx context.Context, key, val string, expiresIn time.Duration, maxWait time.Duration) (bool, string, error) {
if expiresIn < time.Second {
return false, "", fmt.Errorf("lock ttl too short")
}

var ok bool
var lastHolder string
bf := backoff.NewExponentialBackOff()
bf.InitialInterval = 50 * time.Millisecond
bf.MaxInterval = 300 * time.Millisecond
bf.MaxElapsedTime = maxWait

errNotLocked := errors.New("lock hold by others")
err := backoff.Retry(func() error {
result, err := r.c.Eval(ctx, setNXWithGetScript, []string{key}, val, int64(expiresIn/time.Millisecond)).Result()
if err != nil {
return errors.WithMessage(err, fmt.Sprintf("redis setnx with get fail, key: %v", key))
}
sl, okType := result.([]interface{})
if !okType || len(sl) != 2 {
return errors.Errorf("unexpected script result type %T or length", result)
}
locked, _ := sl[0].(int64)
if locked == 1 {
ok = true
return nil
}
switch v := sl[1].(type) {
case string:
lastHolder = v
case []byte:
lastHolder = string(v)
default:
return errors.Errorf("unexpected lua script result type %T or length, key: %v", sl[1], key)
}
return errNotLocked
}, bf)
if err != nil {
if errors.Is(err, errNotLocked) {
return false, lastHolder, nil
}
return false, "", err
}
return ok, val, nil
Comment on lines +253 to +296
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[No Risk] New value-based locking helpers look correct and consistent.

I walked through UnlockWithValue, UnlockForce, Exists, and BackoffLockWithValue. The Lua scripts and retry/backoff configuration align with typical Redis SET NX + expiration patterns, and BackoffLockWithValue cleanly exposes the last holder for higher-level coordination.

No concurrency, correctness, or error-handling issues stand out in this segment.

}
151 changes: 151 additions & 0 deletions backend/infra/lock/lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,157 @@ func newIntCmdResult(val int64, err error) *redis.Cmd {
return cmd
}

// helper to build a redis.Cmd for Eval script result (val is typically []interface{}{locked, holder})
func newEvalCmdResult(val interface{}, err error) *redis.Cmd {
cmd := redis.NewCmd(context.Background())
if err != nil {
cmd.SetErr(err)
return cmd
}
cmd.SetVal(val)
return cmd
}

func TestRedisLocker_BackoffLockWithValue(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

cases := []struct {
name string
expiresIn time.Duration
maxWait time.Duration
setupMock func(m *redisMocks.MockPersistentCmdable)
wantOk bool
wantVal string
wantErr bool
}{
{
name: "ttl_too_short_returns_error",
expiresIn: 100 * time.Millisecond,
maxWait: time.Second,
setupMock: func(m *redisMocks.MockPersistentCmdable) {},
wantOk: false,
wantVal: "",
wantErr: true,
},
{
name: "success_locked_on_first_try",
expiresIn: 2 * time.Second,
maxWait: time.Second,
setupMock: func(m *redisMocks.MockPersistentCmdable) {
m.EXPECT().
Eval(gomock.Any(), setNXWithGetScript, []string{"key1"}, "val1", int64(2000)).
Return(newEvalCmdResult([]interface{}{int64(1), "val1"}, nil)).
Times(1)
},
wantOk: true,
wantVal: "val1",
wantErr: false,
},
{
name: "locked_by_others_returns_holder_string",
expiresIn: 2 * time.Second,
maxWait: 100 * time.Millisecond,
setupMock: func(m *redisMocks.MockPersistentCmdable) {
m.EXPECT().
Eval(gomock.Any(), setNXWithGetScript, gomock.Any(), gomock.Any(), gomock.Any()).
Return(newEvalCmdResult([]interface{}{int64(0), "other_holder"}, nil)).
AnyTimes()
},
wantOk: false,
wantVal: "other_holder",
wantErr: false,
},
{
name: "locked_by_others_returns_holder_bytes",
expiresIn: 2 * time.Second,
maxWait: 100 * time.Millisecond,
setupMock: func(m *redisMocks.MockPersistentCmdable) {
m.EXPECT().
Eval(gomock.Any(), setNXWithGetScript, gomock.Any(), gomock.Any(), gomock.Any()).
Return(newEvalCmdResult([]interface{}{int64(0), []byte("byte_holder")}, nil)).
AnyTimes()
},
wantOk: false,
wantVal: "byte_holder",
wantErr: false,
},
{
name: "redis_error_returns_error",
expiresIn: 2 * time.Second,
maxWait: 100 * time.Millisecond,
setupMock: func(m *redisMocks.MockPersistentCmdable) {
m.EXPECT().
Eval(gomock.Any(), setNXWithGetScript, gomock.Any(), gomock.Any(), gomock.Any()).
Return(newEvalCmdResult(nil, context.DeadlineExceeded)).
AnyTimes()
},
wantOk: false,
wantVal: "",
wantErr: true,
},
{
name: "unexpected_script_result_type_returns_error",
expiresIn: 2 * time.Second,
maxWait: 100 * time.Millisecond,
setupMock: func(m *redisMocks.MockPersistentCmdable) {
m.EXPECT().
Eval(gomock.Any(), setNXWithGetScript, gomock.Any(), gomock.Any(), gomock.Any()).
Return(newEvalCmdResult(interface{}("not_a_slice"), nil)).
AnyTimes()
},
wantOk: false,
wantVal: "",
wantErr: true,
},
{
name: "unexpected_script_result_length_returns_error",
expiresIn: 2 * time.Second,
maxWait: 100 * time.Millisecond,
setupMock: func(m *redisMocks.MockPersistentCmdable) {
m.EXPECT().
Eval(gomock.Any(), setNXWithGetScript, gomock.Any(), gomock.Any(), gomock.Any()).
Return(newEvalCmdResult([]interface{}{int64(0)}, nil)).
AnyTimes()
},
wantOk: false,
wantVal: "",
wantErr: true,
},
{
name: "unexpected_holder_type_returns_error",
expiresIn: 2 * time.Second,
maxWait: 100 * time.Millisecond,
setupMock: func(m *redisMocks.MockPersistentCmdable) {
m.EXPECT().
Eval(gomock.Any(), setNXWithGetScript, gomock.Any(), gomock.Any(), gomock.Any()).
Return(newEvalCmdResult([]interface{}{int64(0), 12345}, nil)).
AnyTimes()
},
wantOk: false,
wantVal: "",
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
mockRedis := redisMocks.NewMockPersistentCmdable(ctrl)
c.setupMock(mockRedis)
locker := &redisLocker{c: mockRedis, holder: "holder"}
ok, val, err := locker.BackoffLockWithValue(context.Background(), "key1", "val1", c.expiresIn, c.maxWait)
if c.wantErr != (err != nil) {
t.Fatalf("err: got %v, wantErr %v", err, c.wantErr)
}
if ok != c.wantOk {
t.Errorf("ok: got %v, want %v", ok, c.wantOk)
}
if val != c.wantVal {
t.Errorf("val: got %q, want %q", val, c.wantVal)
}
})
}
}

func TestRedisLocker_renewLock_ContextDoneUnlocksAndReturns(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
Expand Down
61 changes: 61 additions & 0 deletions backend/infra/lock/mocks/lock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading