├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── README.md.tpl ├── example_test.go ├── go.mod ├── go.sum ├── lock.go ├── lock_test.go └── options.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.11.x 4 | services: 5 | - redis-server 6 | env: 7 | - GO111MODULE=on 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Black Square Media 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: vet test 2 | 3 | vet: 4 | go vet ./... 5 | 6 | test: 7 | go test ./... 8 | 9 | doc: README.md 10 | 11 | .PHONY: default test vet 12 | 13 | README.md: README.md.tpl $(wildcard *.go) 14 | becca -package $(subst $(GOPATH)/src/,,$(PWD)) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redis-lock 2 | 3 | [![Build Status](https://travis-ci.org/bsm/redis-lock.png?branch=master)](https://travis-ci.org/bsm/redis-lock) 4 | [![GoDoc](https://godoc.org/github.com/bsm/redis-lock?status.png)](http://godoc.org/github.com/bsm/redis-lock) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/bsm/redis-lock)](https://goreportcard.com/report/github.com/bsm/redis-lock) 6 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 7 | 8 | ## DEPRECATED 9 | 10 | Please see https://github.com/bsm/redislock instead 11 | -------------------------------------------------------------------------------- /README.md.tpl: -------------------------------------------------------------------------------- 1 | # redis-lock 2 | 3 | [![Build Status](https://travis-ci.org/bsm/redis-lock.png?branch=master)](https://travis-ci.org/bsm/redis-lock) 4 | [![GoDoc](https://godoc.org/github.com/bsm/redis-lock?status.png)](http://godoc.org/github.com/bsm/redis-lock) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/bsm/redis-lock)](https://goreportcard.com/report/github.com/bsm/redis-lock) 6 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 7 | 8 | Simplified distributed locking implementation using [Redis](http://redis.io/topics/distlock). 9 | For more information, please see examples. 10 | 11 | ## Examples 12 | 13 | ```go 14 | import ( 15 | "fmt" 16 | "time" 17 | 18 | "github.com/bsm/redis-lock" 19 | "github.com/go-redis/redis" 20 | ) 21 | 22 | func main() {{ "Example" | code }} 23 | ``` 24 | 25 | ## Documentation 26 | 27 | Full documentation is available on [GoDoc](http://godoc.org/github.com/bsm/redis-lock) 28 | 29 | ## Testing 30 | 31 | Simply run: 32 | 33 | make 34 | 35 | 36 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package lock_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/bsm/redis-lock" 8 | "github.com/go-redis/redis" 9 | ) 10 | 11 | func Example() { 12 | // Connect to Redis 13 | client := redis.NewClient(&redis.Options{ 14 | Network: "tcp", 15 | Addr: "127.0.0.1:6379", 16 | }) 17 | defer client.Close() 18 | 19 | // Create a new locker with default settings 20 | locker := lock.New(client, "lock.foo", nil) 21 | 22 | // Try to obtain lock 23 | hasLock, err := locker.Lock() 24 | if err != nil { 25 | panic(err.Error()) 26 | } else if !hasLock { 27 | fmt.Println("could not obtain lock!") 28 | return 29 | } 30 | 31 | // Don't forget to defer Unlock! 32 | defer locker.Unlock() 33 | fmt.Println("I have a lock!") 34 | 35 | // Sleep and check if still locked afterwards. 36 | time.Sleep(200 * time.Millisecond) 37 | if locker.IsLocked() { 38 | fmt.Println("My lock has expired!") 39 | } 40 | 41 | // Renew your lock 42 | renewed, err := locker.Lock() 43 | if err != nil { 44 | panic(err.Error()) 45 | } else if !renewed { 46 | fmt.Println("could not renew lock!") 47 | return 48 | } 49 | fmt.Println("I have renewed my lock!") 50 | 51 | // Output: 52 | // I have a lock! 53 | // My lock has expired! 54 | // I have renewed my lock! 55 | } 56 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bsm/redis-lock 2 | 3 | require ( 4 | github.com/go-redis/redis v6.14.2+incompatible 5 | github.com/onsi/ginkgo v1.7.0 6 | github.com/onsi/gomega v1.4.3 7 | golang.org/x/net v0.0.0-20181129055619-fae4c4e3ad76 // indirect 8 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect 9 | golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 // indirect 10 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 2 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 3 | github.com/go-redis/redis v6.14.2+incompatible h1:UE9pLhzmWf+xHNmZsoccjXosPicuiNaInPgym8nzfg0= 4 | github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 5 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 6 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 7 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 8 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 9 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 10 | github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= 11 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 12 | github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= 13 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 14 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= 15 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 16 | golang.org/x/net v0.0.0-20181129055619-fae4c4e3ad76/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 17 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= 18 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 19 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs= 21 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 22 | golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 23 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 24 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 27 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 29 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 30 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 31 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 32 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 33 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 34 | -------------------------------------------------------------------------------- /lock.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "errors" 8 | "strconv" 9 | "sync" 10 | "time" 11 | 12 | "github.com/go-redis/redis" 13 | ) 14 | 15 | var luaRefresh = redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`) 16 | var luaRelease = redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`) 17 | 18 | var emptyCtx = context.Background() 19 | 20 | // ErrLockNotObtained may be returned by Obtain() and Run() 21 | // if a lock could not be obtained. 22 | var ( 23 | ErrLockUnlockFailed = errors.New("lock unlock failed") 24 | ErrLockNotObtained = errors.New("lock not obtained") 25 | ErrLockDurationExceeded = errors.New("lock duration exceeded") 26 | ) 27 | 28 | // RedisClient is a minimal client interface. 29 | type RedisClient interface { 30 | SetNX(key string, value interface{}, expiration time.Duration) *redis.BoolCmd 31 | Eval(script string, keys []string, args ...interface{}) *redis.Cmd 32 | EvalSha(sha1 string, keys []string, args ...interface{}) *redis.Cmd 33 | ScriptExists(scripts ...string) *redis.BoolSliceCmd 34 | ScriptLoad(script string) *redis.StringCmd 35 | } 36 | 37 | // Locker allows (repeated) distributed locking. 38 | type Locker struct { 39 | client RedisClient 40 | key string 41 | opts Options 42 | 43 | token string 44 | mutex sync.Mutex 45 | } 46 | 47 | // Run runs a callback handler with a Redis lock. It may return ErrLockNotObtained 48 | // if a lock was not successfully acquired. 49 | func Run(client RedisClient, key string, opts *Options, handler func()) error { 50 | locker, err := Obtain(client, key, opts) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | sem := make(chan struct{}) 56 | go func() { 57 | handler() 58 | close(sem) 59 | }() 60 | 61 | select { 62 | case <-sem: 63 | return locker.Unlock() 64 | case <-time.After(locker.opts.LockTimeout): 65 | return ErrLockDurationExceeded 66 | } 67 | } 68 | 69 | // Obtain is a shortcut for New().Lock(). It may return ErrLockNotObtained 70 | // if a lock was not successfully acquired. 71 | func Obtain(client RedisClient, key string, opts *Options) (*Locker, error) { 72 | locker := New(client, key, opts) 73 | if ok, err := locker.Lock(); err != nil { 74 | return nil, err 75 | } else if !ok { 76 | return nil, ErrLockNotObtained 77 | } 78 | return locker, nil 79 | } 80 | 81 | // New creates a new distributed locker on a given key. 82 | func New(client RedisClient, key string, opts *Options) *Locker { 83 | var o Options 84 | if opts != nil { 85 | o = *opts 86 | } 87 | o.normalize() 88 | 89 | return &Locker{client: client, key: key, opts: o} 90 | } 91 | 92 | // IsLocked returns true if a lock is still being held. 93 | func (l *Locker) IsLocked() bool { 94 | l.mutex.Lock() 95 | locked := l.token != "" 96 | l.mutex.Unlock() 97 | 98 | return locked 99 | } 100 | 101 | // Lock applies the lock, don't forget to defer the Unlock() function to release the lock after usage. 102 | func (l *Locker) Lock() (bool, error) { 103 | return l.LockWithContext(emptyCtx) 104 | } 105 | 106 | // LockWithContext is like Lock but allows to pass an additional context which allows cancelling 107 | // lock attempts prematurely. 108 | func (l *Locker) LockWithContext(ctx context.Context) (bool, error) { 109 | l.mutex.Lock() 110 | defer l.mutex.Unlock() 111 | 112 | if l.token != "" { 113 | return l.refresh(ctx) 114 | } 115 | return l.create(ctx) 116 | } 117 | 118 | // Unlock releases the lock 119 | func (l *Locker) Unlock() error { 120 | l.mutex.Lock() 121 | err := l.release() 122 | l.mutex.Unlock() 123 | 124 | return err 125 | } 126 | 127 | // Helpers 128 | 129 | func (l *Locker) create(ctx context.Context) (bool, error) { 130 | l.reset() 131 | 132 | // Create a random token 133 | token, err := randomToken() 134 | if err != nil { 135 | return false, err 136 | } 137 | token = l.opts.TokenPrefix + token 138 | 139 | // Calculate the timestamp we are willing to wait for 140 | attempts := l.opts.RetryCount + 1 141 | var retryDelay *time.Timer 142 | 143 | for { 144 | 145 | // Try to obtain a lock 146 | ok, err := l.obtain(token) 147 | if err != nil { 148 | return false, err 149 | } else if ok { 150 | l.token = token 151 | return true, nil 152 | } 153 | 154 | if attempts--; attempts <= 0 { 155 | return false, nil 156 | } 157 | 158 | if retryDelay == nil { 159 | retryDelay = time.NewTimer(l.opts.RetryDelay) 160 | defer retryDelay.Stop() 161 | } else { 162 | retryDelay.Reset(l.opts.RetryDelay) 163 | } 164 | 165 | select { 166 | case <-ctx.Done(): 167 | return false, ctx.Err() 168 | case <-retryDelay.C: 169 | } 170 | } 171 | } 172 | 173 | func (l *Locker) refresh(ctx context.Context) (bool, error) { 174 | ttl := strconv.FormatInt(int64(l.opts.LockTimeout/time.Millisecond), 10) 175 | status, err := luaRefresh.Run(l.client, []string{l.key}, l.token, ttl).Result() 176 | if err != nil { 177 | return false, err 178 | } else if status == int64(1) { 179 | return true, nil 180 | } 181 | return l.create(ctx) 182 | } 183 | 184 | func (l *Locker) obtain(token string) (bool, error) { 185 | ok, err := l.client.SetNX(l.key, token, l.opts.LockTimeout).Result() 186 | if err == redis.Nil { 187 | err = nil 188 | } 189 | return ok, err 190 | } 191 | 192 | func (l *Locker) release() error { 193 | defer l.reset() 194 | 195 | res, err := luaRelease.Run(l.client, []string{l.key}, l.token).Result() 196 | if err == redis.Nil { 197 | return ErrLockUnlockFailed 198 | } 199 | 200 | if i, ok := res.(int64); !ok || i != 1 { 201 | return ErrLockUnlockFailed 202 | } 203 | 204 | return err 205 | } 206 | 207 | func (l *Locker) reset() { 208 | l.token = "" 209 | } 210 | 211 | func randomToken() (string, error) { 212 | buf := make([]byte, 16) 213 | if _, err := rand.Read(buf); err != nil { 214 | return "", err 215 | } 216 | return base64.URLEncoding.EncodeToString(buf), nil 217 | } 218 | -------------------------------------------------------------------------------- /lock_test.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "os" 7 | "sync" 8 | "sync/atomic" 9 | "testing" 10 | "time" 11 | 12 | "github.com/go-redis/redis" 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | const testRedisKey = "__bsm_redis_lock_unit_test__" 18 | 19 | var _ = Describe("Locker", func() { 20 | var ( 21 | subject *Locker 22 | ) 23 | hostname, _ := os.Hostname() 24 | tokenPfx := fmt.Sprintf("%s-%d-", hostname, os.Getpid()) 25 | 26 | var newLock = func() *Locker { 27 | return New(redisClient, testRedisKey, &Options{ 28 | RetryCount: 4, 29 | RetryDelay: 25 * time.Millisecond, 30 | LockTimeout: time.Second, 31 | TokenPrefix: tokenPfx, 32 | }) 33 | } 34 | 35 | var getTTL = func() (time.Duration, error) { 36 | return redisClient.PTTL(testRedisKey).Result() 37 | } 38 | 39 | BeforeEach(func() { 40 | subject = newLock() 41 | Expect(subject.IsLocked()).To(BeFalse()) 42 | }) 43 | 44 | AfterEach(func() { 45 | Expect(redisClient.Del(testRedisKey).Err()).NotTo(HaveOccurred()) 46 | }) 47 | 48 | It("should normalize options", func() { 49 | locker := New(redisClient, testRedisKey, &Options{ 50 | LockTimeout: -1, 51 | RetryCount: -1, 52 | RetryDelay: -1, 53 | }) 54 | Expect(locker.opts).To(Equal(Options{ 55 | LockTimeout: 5 * time.Second, 56 | RetryCount: 0, 57 | RetryDelay: 100 * time.Millisecond, 58 | })) 59 | }) 60 | 61 | It("should fail obtain with error", func() { 62 | locker := newLock() 63 | locker.Lock() 64 | defer locker.Unlock() 65 | 66 | _, err := Obtain(redisClient, testRedisKey, nil) 67 | Expect(err).To(Equal(ErrLockNotObtained)) 68 | }) 69 | 70 | It("should obtain through short-cut", func() { 71 | Expect(Obtain(redisClient, testRedisKey, nil)).To(BeAssignableToTypeOf(subject)) 72 | }) 73 | 74 | It("should obtain fresh locks", func() { 75 | Expect(subject.Lock()).To(BeTrue()) 76 | Expect(subject.IsLocked()).To(BeTrue()) 77 | 78 | Expect(redisClient.Get(testRedisKey).Result()).To(HaveLen(24 + len(subject.opts.TokenPrefix))) 79 | Expect(getTTL()).To(BeNumerically("~", time.Second, 10*time.Millisecond)) 80 | }) 81 | 82 | It("should retry if enabled", func() { 83 | Expect(redisClient.Set(testRedisKey, "ABCD", 0).Err()).NotTo(HaveOccurred()) 84 | Expect(redisClient.PExpire(testRedisKey, 30*time.Millisecond).Err()).NotTo(HaveOccurred()) 85 | 86 | Expect(subject.Lock()).To(BeTrue()) 87 | Expect(subject.IsLocked()).To(BeTrue()) 88 | 89 | Expect(redisClient.Get(testRedisKey).Result()).To(Equal(subject.token)) 90 | Expect(subject.opts.TokenPrefix).To(Equal(subject.token[:len(subject.token)-24])) 91 | Expect(getTTL()).To(BeNumerically("~", time.Second, 10*time.Millisecond)) 92 | }) 93 | 94 | It("should not retry if not enabled", func() { 95 | Expect(redisClient.Set(testRedisKey, "ABCD", 0).Err()).NotTo(HaveOccurred()) 96 | Expect(redisClient.PExpire(testRedisKey, 150*time.Millisecond).Err()).NotTo(HaveOccurred()) 97 | subject.opts.RetryCount = 0 98 | 99 | Expect(subject.Lock()).To(BeFalse()) 100 | Expect(subject.IsLocked()).To(BeFalse()) 101 | Expect(getTTL()).To(BeNumerically("~", 150*time.Millisecond, 10*time.Millisecond)) 102 | }) 103 | 104 | It("should give up when retry count reached", func() { 105 | Expect(redisClient.Set(testRedisKey, "ABCD", 0).Err()).NotTo(HaveOccurred()) 106 | Expect(redisClient.PExpire(testRedisKey, 150*time.Millisecond).Err()).NotTo(HaveOccurred()) 107 | 108 | Expect(subject.Lock()).To(BeFalse()) 109 | Expect(subject.IsLocked()).To(BeFalse()) 110 | Expect(subject.token).To(Equal("")) 111 | 112 | Expect(redisClient.Get(testRedisKey).Result()).To(Equal("ABCD")) 113 | Expect(getTTL()).To(BeNumerically("~", 45*time.Millisecond, 20*time.Millisecond)) 114 | }) 115 | 116 | It("should release own locks", func() { 117 | Expect(subject.Lock()).To(BeTrue()) 118 | Expect(subject.IsLocked()).To(BeTrue()) 119 | 120 | Expect(subject.Unlock()).NotTo(HaveOccurred()) 121 | Expect(subject.token).To(Equal("")) 122 | Expect(subject.IsLocked()).To(BeFalse()) 123 | Expect(redisClient.Get(testRedisKey).Err()).To(Equal(redis.Nil)) 124 | }) 125 | 126 | It("should failure on release expired lock", func() { 127 | Expect(subject.Lock()).To(BeTrue()) 128 | Expect(subject.IsLocked()).To(BeTrue()) 129 | 130 | time.Sleep(subject.opts.LockTimeout * 2) 131 | 132 | err := subject.Unlock() 133 | Expect(err).To(Equal(ErrLockUnlockFailed)) 134 | }) 135 | 136 | It("should not release someone else's locks", func() { 137 | Expect(redisClient.Set(testRedisKey, "ABCD", 0).Err()).NotTo(HaveOccurred()) 138 | Expect(subject.IsLocked()).To(BeFalse()) 139 | 140 | err := subject.Unlock() 141 | Expect(err).To(Equal(ErrLockUnlockFailed)) 142 | Expect(subject.token).To(Equal("")) 143 | Expect(subject.IsLocked()).To(BeFalse()) 144 | Expect(redisClient.Get(testRedisKey).Val()).To(Equal("ABCD")) 145 | }) 146 | 147 | It("should refresh locks", func() { 148 | Expect(subject.Lock()).To(BeTrue()) 149 | Expect(subject.IsLocked()).To(BeTrue()) 150 | 151 | time.Sleep(50 * time.Millisecond) 152 | Expect(getTTL()).To(BeNumerically("~", 950*time.Millisecond, 10*time.Millisecond)) 153 | 154 | Expect(subject.Lock()).To(BeTrue()) 155 | Expect(subject.IsLocked()).To(BeTrue()) 156 | Expect(getTTL()).To(BeNumerically("~", time.Second, 10*time.Millisecond)) 157 | }) 158 | 159 | It("should re-create expired locks on refresh", func() { 160 | Expect(subject.Lock()).To(BeTrue()) 161 | Expect(subject.IsLocked()).To(BeTrue()) 162 | token := subject.token 163 | 164 | Expect(redisClient.Del(testRedisKey).Err()).NotTo(HaveOccurred()) 165 | 166 | Expect(subject.Lock()).To(BeTrue()) 167 | Expect(subject.IsLocked()).To(BeTrue()) 168 | Expect(subject.token).NotTo(Equal(token)) 169 | Expect(getTTL()).To(BeNumerically("~", time.Second, 10*time.Millisecond)) 170 | }) 171 | 172 | It("should not re-capture expired locks acquiredby someone else", func() { 173 | Expect(subject.Lock()).To(BeTrue()) 174 | Expect(subject.IsLocked()).To(BeTrue()) 175 | Expect(redisClient.Set(testRedisKey, "ABCD", 0).Err()).NotTo(HaveOccurred()) 176 | 177 | Expect(subject.Lock()).To(BeFalse()) 178 | Expect(subject.IsLocked()).To(BeFalse()) 179 | }) 180 | 181 | It("should prevent multiple locks (fuzzing)", func() { 182 | res := int32(0) 183 | wg := new(sync.WaitGroup) 184 | for i := 0; i < 1000; i++ { 185 | wg.Add(1) 186 | go func() { 187 | defer GinkgoRecover() 188 | defer wg.Done() 189 | 190 | locker := newLock() 191 | wait := rand.Int63n(int64(50 * time.Millisecond)) 192 | time.Sleep(time.Duration(wait)) 193 | 194 | ok, err := locker.Lock() 195 | if err != nil { 196 | atomic.AddInt32(&res, 100) 197 | return 198 | } else if !ok { 199 | return 200 | } 201 | atomic.AddInt32(&res, 1) 202 | }() 203 | } 204 | wg.Wait() 205 | Expect(res).To(Equal(int32(1))) 206 | }) 207 | 208 | It("should error when lock time exceeded while running handler", func() { 209 | err := Run(redisClient, testRedisKey, &Options{LockTimeout: time.Millisecond}, func() { 210 | time.Sleep(time.Millisecond * 5) 211 | }) 212 | 213 | Expect(err).To(Equal(ErrLockDurationExceeded)) 214 | }) 215 | 216 | It("should retry and wait for locks if requested", func() { 217 | var ( 218 | wg sync.WaitGroup 219 | res int32 220 | ) 221 | 222 | wg.Add(1) 223 | go func() { 224 | defer wg.Done() 225 | defer GinkgoRecover() 226 | 227 | err := Run(redisClient, testRedisKey, &Options{RetryCount: 10, RetryDelay: 10 * time.Millisecond}, func() { 228 | atomic.AddInt32(&res, 1) 229 | }) 230 | Expect(err).NotTo(HaveOccurred()) 231 | }() 232 | 233 | err := Run(redisClient, testRedisKey, nil, func() { 234 | atomic.AddInt32(&res, 1) 235 | time.Sleep(20 * time.Millisecond) 236 | }) 237 | wg.Wait() 238 | 239 | Expect(err).NotTo(HaveOccurred()) 240 | Expect(res).To(Equal(int32(2))) 241 | }) 242 | 243 | It("should give up retrying after timeout", func() { 244 | var ( 245 | wg sync.WaitGroup 246 | res int32 247 | ) 248 | 249 | wg.Add(1) 250 | go func() { 251 | defer wg.Done() 252 | defer GinkgoRecover() 253 | 254 | err := Run(redisClient, testRedisKey, &Options{RetryCount: 1, RetryDelay: 10 * time.Millisecond}, func() { 255 | atomic.AddInt32(&res, 1) 256 | }) 257 | Expect(err).To(Equal(ErrLockNotObtained)) 258 | }() 259 | 260 | err := Run(redisClient, testRedisKey, nil, func() { 261 | atomic.AddInt32(&res, 1) 262 | time.Sleep(100 * time.Millisecond) 263 | }) 264 | wg.Wait() 265 | 266 | Expect(err).NotTo(HaveOccurred()) 267 | Expect(res).To(Equal(int32(1))) 268 | }) 269 | 270 | }) 271 | 272 | // -------------------------------------------------------------------- 273 | 274 | func TestSuite(t *testing.T) { 275 | RegisterFailHandler(Fail) 276 | AfterEach(func() { 277 | Expect(redisClient.Del(testRedisKey).Err()).NotTo(HaveOccurred()) 278 | }) 279 | RunSpecs(t, "redis-lock") 280 | } 281 | 282 | var redisClient *redis.Client 283 | 284 | var _ = BeforeSuite(func() { 285 | redisClient = redis.NewClient(&redis.Options{ 286 | Network: "tcp", 287 | Addr: "127.0.0.1:6379", DB: 9, 288 | }) 289 | Expect(redisClient.Ping().Err()).NotTo(HaveOccurred()) 290 | }) 291 | 292 | var _ = AfterSuite(func() { 293 | Expect(redisClient.Close()).To(Succeed()) 294 | }) 295 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import "time" 4 | 5 | // Options describe the options for the lock 6 | type Options struct { 7 | // The maximum duration to lock a key for 8 | // Default: 5s 9 | LockTimeout time.Duration 10 | 11 | // The number of time the acquisition of a lock will be retried. 12 | // Default: 0 = do not retry 13 | RetryCount int 14 | 15 | // RetryDelay is the amount of time to wait between retries. 16 | // Default: 100ms 17 | RetryDelay time.Duration 18 | 19 | // TokenPrefix the redis lock key's value will set TokenPrefix + randomToken 20 | // If we set token prefix as hostname + pid, we can know who get the locker 21 | TokenPrefix string 22 | } 23 | 24 | func (o *Options) normalize() *Options { 25 | if o.LockTimeout < 1 { 26 | o.LockTimeout = 5 * time.Second 27 | } 28 | if o.RetryCount < 0 { 29 | o.RetryCount = 0 30 | } 31 | if o.RetryDelay < 1 { 32 | o.RetryDelay = 100 * time.Millisecond 33 | } 34 | return o 35 | } 36 | --------------------------------------------------------------------------------