Skip to content

Commit 5f6309a

Browse files
authored
Refresh with retry + bug fixes (#88)
1 parent 5f567e8 commit 5f6309a

4 files changed

Lines changed: 269 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
## v0.10.0
2-
3-
- Feature: New ObtainMulti method to acquire multiple locks atomically [#70](https://github.com/bsm/redislock/pull/70)
1+
## Unreleased
2+
3+
- Feature: New ObtainMulti method to acquire multiple locks atomically [#70](https://github.com/bsm/redislock/pull/70)
4+
- Feature: `Lock.Refresh` now accepts an `Options.RetryStrategy` and retries transient redis errors.
5+
- Fix: a lost lock (refresh against a key whose value no longer matches) now returns `ErrNotObtained` immediately instead of being silently retried by the retry loop.
6+
- Fix: `withRetry` surfaces the last transient error when retries are exhausted, instead of masking it as `ErrNotObtained`.
7+
- Fix: when retries are interrupted by a cancelled context, return `errors.Join(ErrNotObtained, ctx.Err())` (or the last transient error joined with `ctx.Err()`) so callers can match on both with `errors.Is`.
8+
- Docs: clarify `ExponentialBackoff` formula and the behaviour of its `min`/`max` arguments (first wait is 4ms; pass `min >= 16ms` for a sensible floor, non-zero `max` to cap growth).
9+
- Chore: bump `go-redis/v9` to v9.19.0 and raise minimum Go to 1.25 [#86](https://github.com/bsm/redislock/pull/86).
410

511
## v0.9.4
612

redislock.go

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -80,27 +80,65 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati
8080

8181
value := token + opt.getMetadata()
8282
ttlVal := strconv.FormatInt(int64(ttl/time.Millisecond), 10)
83-
retry := opt.getRetryStrategy()
8483

85-
// make sure we don't retry forever
84+
if err := withRetry(ctx, ttl, opt.getRetryStrategy(), func(ctx context.Context) (bool, error) {
85+
ok, err := c.obtain(ctx, keys, value, len(token), ttlVal)
86+
if err != nil {
87+
// any non-nil error from obtain is terminal (transient redis
88+
// errors are unlikely to clear within a lock TTL and retrying a
89+
// broken server is futile).
90+
return true, err
91+
}
92+
if ok {
93+
return true, nil
94+
}
95+
// lock is held by someone else; retryable.
96+
return false, nil
97+
}); err != nil {
98+
return nil, err
99+
}
100+
return &Lock{Client: c, keys: keys, value: value, tokenLen: len(token)}, nil
101+
}
102+
103+
// withRetry runs attempt repeatedly until it signals it is done, the retry
104+
// strategy is exhausted, or ctx is done. The attempt function returns
105+
// (done, err):
106+
// - done=true: stop and return err (success when err is nil, otherwise a
107+
// terminal failure).
108+
// - done=false, err=nil: retryable, no error to surface; on exhaustion
109+
// withRetry returns ErrNotObtained.
110+
// - done=false, err!=nil: retryable transient error; on exhaustion
111+
// withRetry returns this last error, and on ctx cancellation it returns
112+
// the last error joined with ctx.Err(). If no transient error was seen,
113+
// ctx cancellation returns ErrNotObtained joined with ctx.Err().
114+
//
115+
// If ctx has no deadline one is derived from ttl as a final safety cap, so a
116+
// caller passing an unbounded retry strategy and a background context cannot
117+
// loop forever.
118+
func withRetry(ctx context.Context, ttl time.Duration, retry RetryStrategy, attempt func(context.Context) (bool, error)) error {
86119
if _, ok := ctx.Deadline(); !ok {
87120
var cancel context.CancelFunc
88121
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(ttl))
89122
defer cancel()
90123
}
91124

92125
var ticker *time.Ticker
126+
var lastErr error
93127
for {
94-
ok, err := c.obtain(ctx, keys, value, len(token), ttlVal)
128+
done, err := attempt(ctx)
129+
if done {
130+
return err
131+
}
95132
if err != nil {
96-
return nil, err
97-
} else if ok {
98-
return &Lock{Client: c, keys: keys, value: value, tokenLen: len(token)}, nil
133+
lastErr = err
99134
}
100135

101136
backoff := retry.NextBackoff()
102137
if backoff < 1 {
103-
return nil, ErrNotObtained
138+
if lastErr != nil {
139+
return lastErr
140+
}
141+
return ErrNotObtained
104142
}
105143

106144
if ticker == nil {
@@ -112,7 +150,11 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati
112150

113151
select {
114152
case <-ctx.Done():
115-
return nil, ctx.Err()
153+
err := lastErr
154+
if err == nil {
155+
err = ErrNotObtained
156+
}
157+
return errors.Join(err, ctx.Err())
116158
case <-ticker.C:
117159
}
118160
}
@@ -210,14 +252,19 @@ func (l *Lock) Refresh(ctx context.Context, ttl time.Duration, opt *Options) err
210252
return ErrNotObtained
211253
}
212254
ttlVal := strconv.FormatInt(int64(ttl/time.Millisecond), 10)
213-
_, err := luaRefresh.Run(ctx, l.client, l.keys, l.value, ttlVal).Result()
214-
if err != nil {
255+
256+
return withRetry(ctx, ttl, opt.getRetryStrategy(), func(ctx context.Context) (bool, error) {
257+
_, err := luaRefresh.Run(ctx, l.client, l.keys, l.value, ttlVal).Result()
258+
if err == nil {
259+
return true, nil
260+
}
215261
if errors.Is(err, redis.Nil) {
216-
return ErrNotObtained
262+
// the lock is no longer held by us; retrying cannot recover it.
263+
return true, ErrNotObtained
217264
}
218-
return err
219-
}
220-
return nil
265+
// transient error; allow the retry strategy to try again.
266+
return false, err
267+
})
221268
}
222269

223270
// Release manually releases the lock.

0 commit comments

Comments
 (0)