Files
zeekayandHanzo Dev 1ad510e19d refactor: follow hanzokv/go v9.22.0 package rename redis -> kv
github.com/hanzokv/go/v9 v9.22.0 renamed its Go package from "redis" to
"kv"; the import path is unchanged. A lock library is typed against the
client it wraps -- the adapter's NewPool takes the client's
UniversalClient and compares against the client's Nil sentinel -- so the
qualifier change is load bearing, not cosmetic.

Client-driven:

- go.mod: github.com/hanzokv/go/v9 v9.21.1 -> v9.22.0
- the adapter for our client speaks kv.UniversalClient / kv.Nil, imported
  unaliased as in hanzokv/go's own sources

Our own vocabulary follows:

- redis/ -> kv/, package redis -> package kv. This is redsync's own
  Pool/Conn/Script abstraction over a KV backend, not anyone's product.
- redis/goredis/v9 -> kv/kvgo (package kvgo). That adapter wraps
  hanzokv/go, not go-redis; the old name said the wrong thing. Its
  example moves with it: examples/goredis/v9 -> examples/kvgo.
- import alias redsyncredis -> redsynckv
- RedisError -> KVError (and its tests)
- comments and README speak kv

Deliberately NOT renamed, because they name foreign things:

- the redigo, rueidis, valkeygo and go-redis v6/v7/v8 adapters, which
  target genuinely different clients, keep their names and their own
  "redis." qualifiers (redigo's redis.Conn, go-redis's redis.Nil)
- the server-side Lua in scripts.go: redis.call is the store's scripting
  API. Rewriting it would change the script source and therefore the
  SHA1 hashes pinned in scripts.go, which TestScripts_Hash verifies
  against a live server.
- upstream links (redis.io distlock post, redigo attribution)

Import paths move from .../v4/redis/... to .../v4/kv/...; the module
major stays v4 by policy.

Verified with a local redis-server: go build ./..., go vet ./...,
go test -count=1 ./... and go test -race -count=1 ./... all pass,
TestScripts_Hash included.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 08:53:51 -07:00

322 lines
7.9 KiB
Go

package redsync
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/hanzokv/lock/v4/kv"
)
// A DelayFunc is used to decide the amount of time to wait between retries.
type DelayFunc func(tries int) time.Duration
// A Mutex is a distributed mutual exclusion lock.
type Mutex struct {
name string
expiry time.Duration
tries int
delayFunc DelayFunc
driftFactor float64
timeoutFactor float64
quorum int
genValueFunc func() (string, error)
value string
until time.Time
shuffle bool
failFast bool
setNXOnExtend bool
pools []kv.Pool
}
// Name returns mutex name (i.e. the KV key).
func (m *Mutex) Name() string {
return m.name
}
// Value returns the current random value. The value will be empty until a lock is acquired (or WithValue option is used).
func (m *Mutex) Value() string {
return m.value
}
// Until returns the time of validity of acquired lock. The value will be zero value until a lock is acquired.
func (m *Mutex) Until() time.Time {
return m.until
}
// TryLock only attempts to lock m once and returns immediately regardless of success or failure without retrying.
func (m *Mutex) TryLock() error {
return m.TryLockContext(context.Background())
}
// TryLockContext only attempts to lock m once and returns immediately regardless of success or failure without retrying.
func (m *Mutex) TryLockContext(ctx context.Context) error {
return m.lockContext(ctx, 1)
}
// Lock locks m. In case it returns an error on failure, you may retry to acquire the lock by calling this method again.
func (m *Mutex) Lock() error {
return m.LockContext(context.Background())
}
// LockContext locks m. In case it returns an error on failure, you may retry to acquire the lock by calling this method again.
func (m *Mutex) LockContext(ctx context.Context) error {
return m.lockContext(ctx, m.tries)
}
// lockContext locks m. In case it returns an error on failure, you may retry to acquire the lock by calling this method again.
func (m *Mutex) lockContext(ctx context.Context, tries int) error {
if ctx == nil {
ctx = context.Background()
}
value, err := m.genValueFunc()
if err != nil {
return err
}
var timer *time.Timer
for i := range tries {
if i != 0 {
if timer == nil {
timer = time.NewTimer(m.delayFunc(i))
} else {
timer.Reset(m.delayFunc(i))
}
select {
case <-ctx.Done():
timer.Stop()
// Exit early if the context is done.
return ErrFailed
case <-timer.C:
// Fall-through when the delay timer completes.
}
}
start := time.Now()
n, err := func() (int, error) {
ctx, cancel := context.WithTimeout(ctx, time.Duration(int64(float64(m.expiry)*m.timeoutFactor)))
defer cancel()
return m.actOnPoolsAsync(func(pool kv.Pool) (bool, error) {
return m.acquire(ctx, pool, value)
})
}()
now := time.Now()
until := now.Add(m.expiry - now.Sub(start) - time.Duration(int64(float64(m.expiry)*m.driftFactor)))
if n >= m.quorum && now.Before(until) {
m.value = value
m.until = until
return nil
}
_, _ = func() (int, error) {
ctx, cancel := context.WithTimeout(ctx, time.Duration(int64(float64(m.expiry)*m.timeoutFactor)))
defer cancel()
return m.actOnPoolsAsync(func(pool kv.Pool) (bool, error) {
return m.release(ctx, pool, value)
})
}()
if i == tries-1 && err != nil {
return err
}
}
return ErrFailed
}
// Unlock unlocks m and returns the status of unlock.
func (m *Mutex) Unlock() (bool, error) {
return m.UnlockContext(context.Background())
}
// UnlockContext unlocks m and returns the status of unlock.
func (m *Mutex) UnlockContext(ctx context.Context) (bool, error) {
n, err := m.actOnPoolsAsync(func(pool kv.Pool) (bool, error) {
return m.release(ctx, pool, m.value)
})
if n < m.quorum {
return false, err
}
return true, nil
}
// Extend resets the mutex's expiry and returns the status of expiry extension.
func (m *Mutex) Extend() (bool, error) {
return m.ExtendContext(context.Background())
}
// ExtendContext resets the mutex's expiry and returns the status of expiry extension.
func (m *Mutex) ExtendContext(ctx context.Context) (bool, error) {
start := time.Now()
n, err := m.actOnPoolsAsync(func(pool kv.Pool) (bool, error) {
return m.touch(ctx, pool, m.value, int(m.expiry/time.Millisecond))
})
if n < m.quorum {
return false, err
}
now := time.Now()
until := now.Add(m.expiry - now.Sub(start) - time.Duration(int64(float64(m.expiry)*m.driftFactor)))
if now.Before(until) {
m.until = until
return true, nil
}
return false, ErrExtendFailed
}
// Valid returns true if the lock acquired through m is still valid. It may
// also return true erroneously if quorum is achieved during the call and at
// least one node then takes long enough to respond for the lock to expire.
//
// Deprecated: Use Until instead. See https://github.com/go-redsync/redsync/issues/72.
func (m *Mutex) Valid() (bool, error) {
return m.ValidContext(context.Background())
}
// ValidContext returns true if the lock acquired through m is still valid. It may
// also return true erroneously if quorum is achieved during the call and at
// least one node then takes long enough to respond for the lock to expire.
//
// Deprecated: Use Until instead. See https://github.com/go-redsync/redsync/issues/72.
func (m *Mutex) ValidContext(ctx context.Context) (bool, error) {
n, err := m.actOnPoolsAsync(func(pool kv.Pool) (bool, error) {
return m.valid(ctx, pool)
})
return n >= m.quorum, err
}
func (m *Mutex) valid(ctx context.Context, pool kv.Pool) (bool, error) {
if m.value == "" {
return false, nil
}
conn, err := pool.Get(ctx)
if err != nil {
return false, err
}
defer conn.Close()
reply, err := conn.Get(m.name)
if err != nil {
return false, err
}
return m.value == reply, nil
}
func genValue() (string, error) {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
func (m *Mutex) acquire(ctx context.Context, pool kv.Pool, value string) (bool, error) {
conn, err := pool.Get(ctx)
if err != nil {
return false, err
}
defer conn.Close()
reply, err := conn.SetNX(m.name, value, m.expiry)
if err != nil {
return false, err
}
return reply, nil
}
func (m *Mutex) release(ctx context.Context, pool kv.Pool, value string) (bool, error) {
conn, err := pool.Get(ctx)
if err != nil {
return false, err
}
defer conn.Close()
status, err := conn.Eval(deleteScript, m.name, value)
if err != nil {
return false, err
}
if status == int64(-1) {
return false, ErrLockAlreadyExpired
}
return status != int64(0), nil
}
func (m *Mutex) touch(ctx context.Context, pool kv.Pool, value string, expiry int) (bool, error) {
conn, err := pool.Get(ctx)
if err != nil {
return false, err
}
defer conn.Close()
touchScript := touchScript
if m.setNXOnExtend {
touchScript = touchWithSetNXScript
}
status, err := conn.Eval(touchScript, m.name, value, expiry)
if err != nil {
return false, err
}
return status != int64(0), nil
}
func (m *Mutex) actOnPoolsAsync(actFn func(kv.Pool) (bool, error)) (int, error) {
type result struct {
node int
statusOK bool
err error
}
ch := make(chan result, len(m.pools))
for node, pool := range m.pools {
go func(node int, pool kv.Pool) {
r := result{node: node}
r.statusOK, r.err = actFn(pool)
ch <- r
}(node, pool)
}
var (
n = 0
taken []int
err error
)
for range m.pools {
r := <-ch
if r.statusOK {
n++
} else if r.err == ErrLockAlreadyExpired {
err = errors.Join(err, ErrLockAlreadyExpired)
} else if r.err != nil {
err = errors.Join(err, &KVError{Node: r.node, Err: r.err})
} else {
taken = append(taken, r.node)
err = errors.Join(err, &ErrNodeTaken{Node: r.node})
}
if m.failFast {
// fast return
if n >= m.quorum {
return n, err
}
// fail fast
if len(taken) >= m.quorum {
return n, &ErrTaken{Nodes: taken}
}
}
}
if len(taken) >= m.quorum {
return n, &ErrTaken{Nodes: taken}
}
return n, err
}