github.com/benz9527/xboot@v0.0.0-20240504061247-c23f15593274/dlock/redis_dlock.go (about)

     1  package dlock
     2  
     3  // References:
     4  // https://github.com/bsm/redislock
     5  // https://github.com/go-redsync/redsync
     6  // https://redis.io/docs/latest/develop/use/patterns/distributed-locks/
     7  
     8  import (
     9  	"context"
    10  	_ "embed"
    11  	"errors"
    12  	"sync/atomic"
    13  	"time"
    14  
    15  	"github.com/redis/go-redis/v9"
    16  	"go.uber.org/multierr"
    17  
    18  	"github.com/benz9527/xboot/lib/id"
    19  	"github.com/benz9527/xboot/lib/infra"
    20  )
    21  
    22  //go:embed lock.lua
    23  var luaDLockAcquireScript string
    24  
    25  //go:embed unlock.lua
    26  var luaDLockReleaseScript string
    27  
    28  //go:embed lockrenewal.lua
    29  var luaDLockRenewalTTLScript string
    30  
    31  //go:embed lockttl.lua
    32  var luaDLockLoadTTLScript string
    33  
    34  var (
    35  	luaDLockAcquire    = redis.NewScript(luaDLockAcquireScript)
    36  	luaDLockRelease    = redis.NewScript(luaDLockReleaseScript)
    37  	luaDLockRenewalTTL = redis.NewScript(luaDLockRenewalTTLScript)
    38  	luaDLockLoadTTL    = redis.NewScript(luaDLockLoadTTLScript)
    39  )
    40  
    41  const randomTokenLength = 16
    42  
    43  var nano, _ = id.ClassicNanoID(randomTokenLength)
    44  
    45  var _ DLocker = (*redisDLock)(nil)
    46  
    47  // TODO: Enable watchdog to renewal lock automatically
    48  type redisDLock struct {
    49  	*redisDLockOptions
    50  	locked atomic.Bool
    51  }
    52  
    53  func (dl *redisDLock) Lock() error {
    54  	retry := dl.strategy
    55  	var (
    56  		ticker *time.Ticker
    57  		merr   error
    58  	)
    59  	for {
    60  		if _, err := luaDLockAcquire.Eval(
    61  			*dl.ctx.Load(), // TODO pay attention to the context has been cancelled.
    62  			dl.scripterLoader(),
    63  			dl.keys,
    64  			dl.token, len(dl.token), dl.ttl.Milliseconds(),
    65  		).Result(); err != nil && !errors.Is(err, redis.Nil) {
    66  			merr = multierr.Append(merr, err)
    67  		} else if err == nil || errors.Is(err, redis.Nil) {
    68  			dl.locked.Store(true)
    69  			return noErr
    70  		}
    71  
    72  		backoff := retry.Next()
    73  		if backoff.Milliseconds() < 1 {
    74  			if ticker != nil {
    75  				return infra.WrapErrorStackWithMessage(multierr.Combine(merr, ErrDLockAcquireFailed), "redis dlock lock retry reach to max")
    76  			}
    77  			// No retry strategy.
    78  			return noErr
    79  		}
    80  
    81  		if ticker == nil {
    82  			ticker = time.NewTicker(backoff)
    83  			defer ticker.Stop() // Avoid ticker leak.
    84  		} else {
    85  			ticker.Reset(backoff)
    86  		}
    87  
    88  		select {
    89  		case <-(*dl.ctx.Load()).Done():
    90  			return infra.WrapErrorStack((*dl.ctx.Load()).Err())
    91  		case <-ticker.C:
    92  			// continue
    93  		}
    94  	}
    95  }
    96  
    97  func (dl *redisDLock) Renewal(newTTL time.Duration) error {
    98  	if newTTL.Milliseconds() <= 0 {
    99  		return infra.NewErrorStack("renewal dlock with zero ms TTL")
   100  	}
   101  	if !dl.locked.Load() {
   102  		return infra.WrapErrorStackWithMessage(ErrDLockNoInit, "renewal dlock with no lock")
   103  	}
   104  	var (
   105  		ctx    context.Context
   106  		cancel context.CancelFunc
   107  	)
   108  	// TODO pay attention to the context has been cancelled.
   109  	ctx, cancel = context.WithTimeout(*dl.ctx.Load(), newTTL)
   110  	if ctx != nil && cancel != nil {
   111  		if _, err := luaDLockRenewalTTL.Eval(
   112  			ctx,
   113  			dl.scripterLoader(),
   114  			dl.keys,
   115  			dl.token, newTTL.Milliseconds(),
   116  		).Result(); err != nil && !errors.Is(err, redis.Nil) {
   117  			return err
   118  		}
   119  		dl.ctx.Store(&ctx)
   120  		dl.ctxCancel.Store(&cancel)
   121  		return noErr
   122  	}
   123  	return infra.NewErrorStack("refresh dlock ttl with nil context or nil context cancel function")
   124  }
   125  
   126  func (dl *redisDLock) TTL() (time.Duration, error) {
   127  	if !dl.locked.Load() {
   128  		return 0, infra.WrapErrorStackWithMessage(ErrDLockNoInit, "fetch dlock ttl failed")
   129  	}
   130  	res, err := luaDLockLoadTTL.Eval(
   131  		*dl.ctx.Load(), // TODO pay attention to the context has been cancelled.
   132  		dl.scripterLoader(),
   133  		dl.keys,
   134  		dl.token,
   135  	).Result()
   136  	if err != nil && !errors.Is(err, redis.Nil) {
   137  		return 0, err
   138  	}
   139  	if res == nil {
   140  		return 0, infra.NewErrorStack("no error but nil dlock ttl value")
   141  	}
   142  	if num := res.(int64); num > 0 {
   143  		return time.Duration(num) * time.Millisecond, noErr
   144  	}
   145  	return 0, noErr
   146  }
   147  
   148  func (dl *redisDLock) Unlock() error {
   149  	if !dl.locked.Load() {
   150  		return infra.WrapErrorStackWithMessage(ErrDLockNoInit, "attempt to unlock a no init dlock")
   151  	}
   152  	if _, err := luaDLockRelease.Eval(
   153  		*dl.ctx.Load(), // TODO pay attention to the context has been cancelled.
   154  		dl.scripterLoader(),
   155  		dl.keys,
   156  		dl.token,
   157  	).Result(); err != nil && !errors.Is(err, redis.Nil) {
   158  		return err
   159  	}
   160  	if cancel := *dl.ctxCancel.Load(); cancel != nil {
   161  		cancel()
   162  	}
   163  	return noErr
   164  }
   165  
   166  type redisDLockOptions struct {
   167  	ctx            atomic.Pointer[context.Context]
   168  	ctxCancel      atomic.Pointer[context.CancelFunc]
   169  	scripterLoader func() redis.Scripter
   170  	strategy       RetryStrategy
   171  	keys           []string
   172  	token          string
   173  	ttl            time.Duration
   174  }
   175  
   176  func RedisDLockBuilder(ctx context.Context, scripter func() redis.Scripter) *redisDLockOptions {
   177  	if ctx == nil {
   178  		ctx = context.Background()
   179  	}
   180  	opts := &redisDLockOptions{scripterLoader: scripter}
   181  	opts.ctx.Store(&ctx)
   182  	return opts
   183  }
   184  
   185  func (opt *redisDLockOptions) TTL(ttl time.Duration) *redisDLockOptions {
   186  	opt.ttl = ttl
   187  	return opt
   188  }
   189  
   190  func (opt *redisDLockOptions) Token(token string) *redisDLockOptions {
   191  	opt.token = token + "&" + nano()
   192  	return opt
   193  }
   194  
   195  func (opt *redisDLockOptions) Keys(keys ...string) *redisDLockOptions {
   196  	opt.keys = make([]string, len(keys))
   197  	for i, key := range keys {
   198  		opt.keys[i] = key
   199  	}
   200  	return opt
   201  }
   202  
   203  func (opt *redisDLockOptions) Retry(strategy RetryStrategy) *redisDLockOptions {
   204  	opt.strategy = strategy
   205  	return opt
   206  }
   207  
   208  func (opt *redisDLockOptions) Build() (DLocker, error) {
   209  	if opt.scripterLoader == nil {
   210  		return nil, infra.NewErrorStack("redis dlock scripter loader is nil")
   211  	}
   212  	if opt.ttl.Milliseconds() <= 0 {
   213  		return nil, infra.NewErrorStack("redis dlock with zero ms TTL")
   214  	}
   215  	if len(opt.keys) <= 0 {
   216  		return nil, infra.NewErrorStack("redis dlock with zero keys")
   217  	}
   218  	if opt.strategy == nil {
   219  		opt.strategy = NoRetry()
   220  	}
   221  	var (
   222  		ctx    context.Context
   223  		cancel context.CancelFunc
   224  	)
   225  	ctx, cancel = context.WithTimeout(*opt.ctx.Load(), opt.ttl)
   226  	if ctx == nil || cancel == nil {
   227  		return nil, infra.NewErrorStack("redis dlock build with nil context or nil context cancel function")
   228  	}
   229  	opt.ctx.Store(&ctx)
   230  	opt.ctxCancel.Store(&cancel)
   231  	return &redisDLock{redisDLockOptions: opt}, nil
   232  }
   233  
   234  type RedisDLockOption func(opt *redisDLockOptions)
   235  
   236  func WithRedisDLockTTL(ttl time.Duration) RedisDLockOption {
   237  	return func(opt *redisDLockOptions) {
   238  		opt.TTL(ttl)
   239  	}
   240  }
   241  
   242  func WithRedisDLockKeys(keys ...string) RedisDLockOption {
   243  	return func(opt *redisDLockOptions) {
   244  		opt.Keys(keys...)
   245  	}
   246  }
   247  
   248  func WithRedisDLockToken(token string) RedisDLockOption {
   249  	return func(opt *redisDLockOptions) {
   250  		opt.Token(token)
   251  	}
   252  }
   253  
   254  func WithRedisDLockRetry(strategy RetryStrategy) RedisDLockOption {
   255  	return func(opt *redisDLockOptions) {
   256  		opt.Retry(strategy)
   257  	}
   258  }
   259  
   260  func RedisDLock(ctx context.Context, scripter func() redis.Scripter, opts ...RedisDLockOption) (DLocker, error) {
   261  	builderOpts := RedisDLockBuilder(ctx, scripter)
   262  	for _, o := range opts {
   263  		o(builderOpts)
   264  	}
   265  	return builderOpts.Build()
   266  }