From 2788e18fad51da7cfdd50064e3246c035f09efcc Mon Sep 17 00:00:00 2001 From: Nijat Date: Thu, 25 Jun 2026 16:05:53 +0400 Subject: [PATCH 1/3] Add optional fencing token minted atomically on Obtain Set Options.Fence to mint a fencing token with the lock: a strictly increasing value, incremented atomically inside obtain.lua on each new acquisition and returned by Lock.FenceToken. Stamp writes to the protected resource with the token and reject any write carrying an older one, fencing out a stale lock holder per Kleppmann's "How to do distributed locking". The token is minted only on a genuinely new acquisition; a re-entrant override by the current holder returns the existing token without incrementing. The counter is stored at ":fence" and persists across release so it keeps increasing. Without Options.Fence the script returns the original "OK" status, preserving backward compatibility. --- README.md | 47 +++++++++++++++++++++++++++++++ README.md.tpl | 28 ++++++++++++++++++ example_test.go | 21 ++++++++++++++ obtain.lua | 21 ++++++++++++-- redislock.go | 52 +++++++++++++++++++++++++++------- redislock_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f37e3b8..eb799d4 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,53 @@ func main() { } ``` +### Fencing tokens + +Set `Options.Fence` to mint a **fencing token** with the lock: a strictly +increasing value, incremented atomically on each new acquisition and returned by +`Lock.FenceToken`. Stamp every write to the protected resource with the token and +have the resource reject any write carrying an older token. A lock holder that +pauses (GC, scheduling) long enough to lose the lock without noticing is then +fenced out — its writes carry a stale token and are refused. This is the +mitigation described in Martin Kleppmann's +[How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html). + +```go +func fence() { + client := redis.NewClient(&redis.Options{Network: "tcp", Addr: "127.0.0.1:6379"}) + defer client.Close() + + locker := redislock.New(client) + + ctx := context.Background() + + // Obtain a lock with a fencing token. + lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{Fence: true}) + if err != nil { + log.Fatalln(err) + } + defer lock.Release(ctx) + + // Stamp writes to the protected resource with the token; reject older ones. + if token, ok := lock.FenceToken(); ok { + fmt.Printf("fenced write with token %d\n", token) + } +} +``` + +A few notes: + +- Enforcement is the resource's job: `redislock` mints and returns the token, but + the check (`reject if incoming < highest applied`) must happen atomically at the + resource, which is the only place the comparison and the write can be made one + operation. +- The token is stored at `:fence` (the first key, for multi-key locks) and + persists across release so it keeps increasing; it is never reset by `Release`. +- The token is only as monotonic as the underlying Redis. On a single instance it + is strict; on a Sentinel/Cluster failover that loses the `INCR`, it can regress. + For strict cross-failover monotonicity, source the token from a linearizable + store instead. + ### External watchdog `redislock` deliberately does not bundle a built-in watchdog goroutine: refresh diff --git a/README.md.tpl b/README.md.tpl index cd541d0..e237b71 100644 --- a/README.md.tpl +++ b/README.md.tpl @@ -27,6 +27,34 @@ import ( func main() {{ "Example" | code }} ``` +### Fencing tokens + +Set `Options.Fence` to mint a **fencing token** with the lock: a strictly +increasing value, incremented atomically on each new acquisition and returned by +`Lock.FenceToken`. Stamp every write to the protected resource with the token and +have the resource reject any write carrying an older token. A lock holder that +pauses (GC, scheduling) long enough to lose the lock without noticing is then +fenced out — its writes carry a stale token and are refused. This is the +mitigation described in Martin Kleppmann's +[How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html). + +```go +func fence() {{ "ExampleClient_Obtain_fence" | code }} +``` + +A few notes: + +- Enforcement is the resource's job: `redislock` mints and returns the token, but + the check (`reject if incoming < highest applied`) must happen atomically at the + resource, which is the only place the comparison and the write can be made one + operation. +- The token is stored at `:fence` (the first key, for multi-key locks) and + persists across release so it keeps increasing; it is never reset by `Release`. +- The token is only as monotonic as the underlying Redis. On a single instance it + is strict; on a Sentinel/Cluster failover that loses the `INCR`, it can regress. + For strict cross-failover monotonicity, source the token from a linearizable + store instead. + ### External watchdog `redislock` deliberately does not bundle a built-in watchdog goroutine: refresh diff --git a/example_test.go b/example_test.go index d8e9023..6c1643f 100644 --- a/example_test.go +++ b/example_test.go @@ -89,6 +89,27 @@ func ExampleClient_Obtain_retry() { fmt.Println("I have a lock!") } +func ExampleClient_Obtain_fence() { + client := redis.NewClient(&redis.Options{Network: "tcp", Addr: "127.0.0.1:6379"}) + defer client.Close() + + locker := redislock.New(client) + + ctx := context.Background() + + // Obtain a lock with a fencing token. + lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{Fence: true}) + if err != nil { + log.Fatalln(err) + } + defer lock.Release(ctx) + + // Stamp writes to the protected resource with the token; reject older ones. + if token, ok := lock.FenceToken(); ok { + fmt.Printf("fenced write with token %d\n", token) + } +} + func ExampleLock_Refresh_watchdog() { client := redis.NewClient(&redis.Options{Network: "tcp", Addr: "127.0.0.1:6379"}) defer client.Close() diff --git a/obtain.lua b/obtain.lua index 11e55d6..b938f65 100644 --- a/obtain.lua +++ b/obtain.lua @@ -1,6 +1,7 @@ --- obtain.lua: arguments => [value, tokenLen, ttl] +-- obtain.lua: arguments => [value, tokenLen, ttl, fenceKey] -- Obtain.lua try to set provided keys's with value and ttl if they do not exists. -- Keys can be overriden if they already exists and the correct value+tokenLen is provided. +-- When fenceKey is set, returns a fencing token instead of "OK". local function pexpire(ttl) -- Update keys ttls. @@ -22,6 +23,20 @@ local function canOverrideKeys() return true end +local fenceKey = ARGV[4] + +-- reply returns the fencing token, advancing it only on a fresh acquisition, +-- or "OK" when fencing is disabled. +local function reply(fresh) + if fenceKey == nil or fenceKey == "" then + return redis.status_reply("OK") + end + if fresh then + return redis.call("incr", fenceKey) + end + return tonumber(redis.call("get", fenceKey) or "0") +end + -- Prepare mset arguments. local setArgs = {} for _, key in ipairs(KEYS) do @@ -34,7 +49,9 @@ if redis.call("msetnx", unpack(setArgs)) ~= 1 then return false end redis.call("mset", unpack(setArgs)) + pexpire(ARGV[3]) + return reply(false) end pexpire(ARGV[3]) -return redis.status_reply("OK") \ No newline at end of file +return reply(true) diff --git a/redislock.go b/redislock.go index 5219420..a6c1b70 100644 --- a/redislock.go +++ b/redislock.go @@ -81,8 +81,16 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati value := token + opt.getMetadata() ttlVal := strconv.FormatInt(int64(ttl/time.Millisecond), 10) + fence := opt.getFence() + fenceKey := "" + if fence { + fenceKey = keys[0] + ":fence" + } + + var fenceToken int64 + if err := withRetry(ctx, ttl, opt.getRetryStrategy(), func(ctx context.Context) (bool, error) { - ok, err := c.obtain(ctx, keys, value, len(token), ttlVal) + ok, ft, err := c.obtain(ctx, keys, value, len(token), ttlVal, fenceKey) if err != nil { // any non-nil error from obtain is terminal (transient redis // errors are unlikely to clear within a lock TTL and retrying a @@ -90,6 +98,7 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati return true, err } if ok { + fenceToken = ft return true, nil } // lock is held by someone else; retryable. @@ -97,7 +106,7 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati }); err != nil { return nil, err } - return &Lock{Client: c, keys: keys, value: value, tokenLen: len(token)}, nil + return &Lock{Client: c, keys: keys, value: value, tokenLen: len(token), fenceToken: fenceToken, fenced: fence}, nil } // withRetry runs attempt repeatedly until it signals it is done, the retry @@ -160,15 +169,21 @@ func withRetry(ctx context.Context, ttl time.Duration, retry RetryStrategy, atte } } -func (c *Client) obtain(ctx context.Context, keys []string, value string, tokenLen int, ttlVal string) (bool, error) { - _, err := luaObtain.Run(ctx, c.client, keys, value, tokenLen, ttlVal).Result() +func (c *Client) obtain(ctx context.Context, keys []string, value string, tokenLen int, ttlVal, fenceKey string) (ok bool, fenceToken int64, err error) { + res, err := luaObtain.Run(ctx, c.client, keys, value, tokenLen, ttlVal, fenceKey).Result() if err != nil { if errors.Is(err, redis.Nil) { - return false, nil + return false, 0, nil } - return false, err + return false, 0, err + } + + // With fencing the reply is the integer token; otherwise it is the "OK" status. + if token, isInt := res.(int64); isInt { + return true, token, nil } - return true, nil + + return true, 0, nil } func (c *Client) randomToken() (string, error) { @@ -190,9 +205,11 @@ func (c *Client) randomToken() (string, error) { // Lock represents an obtained, distributed lock. type Lock struct { *Client - keys []string - value string - tokenLen int + keys []string + value string + tokenLen int + fenceToken int64 + fenced bool } // Obtain is a short-cut for New(...).Obtain(...). @@ -221,6 +238,12 @@ func (l *Lock) Token() string { return l.value[:l.tokenLen] } +// FenceToken returns the lock's fencing token, or false if it was obtained +// without Options.Fence. +func (l *Lock) FenceToken() (int64, bool) { + return l.fenceToken, l.fenced +} + // Metadata returns the metadata of the lock. func (l *Lock) Metadata() string { return l.value[l.tokenLen:] @@ -297,6 +320,11 @@ type Options struct { // Token is a unique value that is used to identify the lock. By default, a random tokens are generated. Use this // option to provide a custom token instead. Token string + + // Fence enables a fencing token, minted on each new acquisition and + // returned by Lock.FenceToken. Stored at ":fence". + // Default: disabled. + Fence bool } func (o *Options) getMetadata() string { @@ -313,6 +341,10 @@ func (o *Options) getToken() string { return "" } +func (o *Options) getFence() bool { + return o != nil && o.Fence +} + func (o *Options) getRetryStrategy() RetryStrategy { if o != nil && o.RetryStrategy != nil { return o.RetryStrategy diff --git a/redislock_test.go b/redislock_test.go index 5f0b687..7747bc3 100644 --- a/redislock_test.go +++ b/redislock_test.go @@ -113,6 +113,78 @@ func TestObtain_custom_token(t *testing.T) { } } +func TestObtain_fence(t *testing.T) { + rc := redisConnect(t) + lockKey := rc.lockKey() + rc.keys = append(rc.keys, lockKey+":fence") + + // no token without the Fence option + plain := quickObtain(t, time.Hour) + defer plain.Release(t.Context()) + if _, ok := plain.FenceToken(); ok { + t.Fatal("expected no fence token without Options.Fence") + } + if err := plain.Release(t.Context()); err != nil { + t.Fatal(err) + } + + // first fenced obtain + lock1, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Fence: true}) + if err != nil { + t.Fatal(err) + } + tok1, ok := lock1.FenceToken() + if !ok { + t.Fatal("expected a fence token with Options.Fence") + } + if exp, got := int64(1), tok1; exp != got { + t.Fatalf("expected first fence token %v, got %v", exp, got) + } + + if err := lock1.Release(t.Context()); err != nil { + t.Fatal(err) + } + + // next obtain must mint a greater token + lock2, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Fence: true}) + if err != nil { + t.Fatal(err) + } + defer lock2.Release(t.Context()) + + tok2, _ := lock2.FenceToken() + if tok2 <= tok1 { + t.Fatalf("expected fence token > %v, got %v", tok1, tok2) + } +} + +func TestObtain_fence_reentrant(t *testing.T) { + rc := redisConnect(t) + lockKey := rc.lockKey() + rc.keys = append(rc.keys, lockKey+":fence") + + // obtain with a known token + lock1, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Token: "foo", Fence: true}) + if err != nil { + t.Fatal(err) + } + defer lock1.Release(t.Context()) + + tok1, _ := lock1.FenceToken() + + // re-obtain (override) must not advance the token + lock2, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Token: "foo", Fence: true}) + if err != nil { + t.Fatal(err) + } + defer lock2.Release(t.Context()) + + tok2, _ := lock2.FenceToken() + if exp, got := tok1, tok2; exp != got { + t.Fatalf("re-entrant override must not mint a new token: expected %v, got %v", exp, got) + } +} + func TestObtain_retry_success(t *testing.T) { rc := redisConnect(t) lockKey := rc.lockKey() From ab183a0b63b50b76483e4a36285268bd5c9a3e54 Mon Sep 17 00:00:00 2001 From: Nijat Date: Fri, 26 Jun 2026 00:19:19 +0400 Subject: [PATCH 2/3] Simplify FenceToken to return int64 with 0 meaning unfenced Per review: FenceToken now returns a single int64 instead of (int64, bool). Tokens start at 1, so 0 is an unambiguous "not fenced" sentinel; documented on the method. Drops the now-redundant fenced field. --- README.md | 5 +++-- example_test.go | 5 +++-- redislock.go | 11 +++++------ redislock_test.go | 17 ++++++----------- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index eb799d4..987fae2 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,9 @@ func fence() { } defer lock.Release(ctx) - // Stamp writes to the protected resource with the token; reject older ones. - if token, ok := lock.FenceToken(); ok { + // FenceToken is 0 when the lock was obtained without Options.Fence. Stamp + // writes to the protected resource with the token; reject older ones. + if token := lock.FenceToken(); token != 0 { fmt.Printf("fenced write with token %d\n", token) } } diff --git a/example_test.go b/example_test.go index 6c1643f..091c125 100644 --- a/example_test.go +++ b/example_test.go @@ -104,8 +104,9 @@ func ExampleClient_Obtain_fence() { } defer lock.Release(ctx) - // Stamp writes to the protected resource with the token; reject older ones. - if token, ok := lock.FenceToken(); ok { + // FenceToken is 0 when the lock was obtained without Options.Fence. Stamp + // writes to the protected resource with the token; reject older ones. + if token := lock.FenceToken(); token != 0 { fmt.Printf("fenced write with token %d\n", token) } } diff --git a/redislock.go b/redislock.go index a6c1b70..89e1dc0 100644 --- a/redislock.go +++ b/redislock.go @@ -106,7 +106,7 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati }); err != nil { return nil, err } - return &Lock{Client: c, keys: keys, value: value, tokenLen: len(token), fenceToken: fenceToken, fenced: fence}, nil + return &Lock{Client: c, keys: keys, value: value, tokenLen: len(token), fenceToken: fenceToken}, nil } // withRetry runs attempt repeatedly until it signals it is done, the retry @@ -209,7 +209,6 @@ type Lock struct { value string tokenLen int fenceToken int64 - fenced bool } // Obtain is a short-cut for New(...).Obtain(...). @@ -238,10 +237,10 @@ func (l *Lock) Token() string { return l.value[:l.tokenLen] } -// FenceToken returns the lock's fencing token, or false if it was obtained -// without Options.Fence. -func (l *Lock) FenceToken() (int64, bool) { - return l.fenceToken, l.fenced +// FenceToken returns the lock's fencing token, or 0 if it was obtained without +// Options.Fence. Tokens start at 1, so 0 always means unfenced. +func (l *Lock) FenceToken() int64 { + return l.fenceToken } // Metadata returns the metadata of the lock. diff --git a/redislock_test.go b/redislock_test.go index 7747bc3..53f58b1 100644 --- a/redislock_test.go +++ b/redislock_test.go @@ -121,8 +121,8 @@ func TestObtain_fence(t *testing.T) { // no token without the Fence option plain := quickObtain(t, time.Hour) defer plain.Release(t.Context()) - if _, ok := plain.FenceToken(); ok { - t.Fatal("expected no fence token without Options.Fence") + if got := plain.FenceToken(); got != 0 { + t.Fatalf("expected no fence token without Options.Fence, got %v", got) } if err := plain.Release(t.Context()); err != nil { t.Fatal(err) @@ -133,10 +133,7 @@ func TestObtain_fence(t *testing.T) { if err != nil { t.Fatal(err) } - tok1, ok := lock1.FenceToken() - if !ok { - t.Fatal("expected a fence token with Options.Fence") - } + tok1 := lock1.FenceToken() if exp, got := int64(1), tok1; exp != got { t.Fatalf("expected first fence token %v, got %v", exp, got) } @@ -152,8 +149,7 @@ func TestObtain_fence(t *testing.T) { } defer lock2.Release(t.Context()) - tok2, _ := lock2.FenceToken() - if tok2 <= tok1 { + if tok2 := lock2.FenceToken(); tok2 <= tok1 { t.Fatalf("expected fence token > %v, got %v", tok1, tok2) } } @@ -170,7 +166,7 @@ func TestObtain_fence_reentrant(t *testing.T) { } defer lock1.Release(t.Context()) - tok1, _ := lock1.FenceToken() + tok1 := lock1.FenceToken() // re-obtain (override) must not advance the token lock2, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Token: "foo", Fence: true}) @@ -179,8 +175,7 @@ func TestObtain_fence_reentrant(t *testing.T) { } defer lock2.Release(t.Context()) - tok2, _ := lock2.FenceToken() - if exp, got := tok1, tok2; exp != got { + if exp, got := tok1, lock2.FenceToken(); exp != got { t.Fatalf("re-entrant override must not mint a new token: expected %v, got %v", exp, got) } } From 29b43ec46e12b16c4bc1ef94a4ef919832247979 Mon Sep 17 00:00:00 2001 From: Nijat Date: Fri, 26 Jun 2026 21:02:37 +0400 Subject: [PATCH 3/3] Make the fence key caller-supplied via Options.FenceKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: replace the auto-derived fence key (keys[0]+":fence") with a caller-supplied Options.FenceKey, so the caller controls its placement — important on Redis Cluster, where the fence key must hash to the same slot as the lock key(s). The fence key is also passed in KEYS rather than ARGV (as the last entry, flagged by ARGV[4]) so Redis validates the slot up front: a misplaced fence key fails with CROSSSLOT before the script runs instead of mid-execution. --- README.md | 14 ++++++++------ README.md.tpl | 9 ++++++--- example_test.go | 5 ++--- obtain.lua | 34 +++++++++++++++++++++------------- redislock.go | 38 +++++++++++++++++++++++++------------- redislock_test.go | 18 ++++++++++-------- 6 files changed, 72 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 987fae2..c2cc2c7 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ func main() { ### Fencing tokens -Set `Options.Fence` to mint a **fencing token** with the lock: a strictly +Set `Options.FenceKey` to mint a **fencing token** with the lock: a strictly increasing value, incremented atomically on each new acquisition and returned by `Lock.FenceToken`. Stamp every write to the protected resource with the token and have the resource reject any write carrying an older token. A lock holder that @@ -96,14 +96,13 @@ func fence() { ctx := context.Background() // Obtain a lock with a fencing token. - lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{Fence: true}) + lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{FenceKey: "my-key:fence"}) if err != nil { log.Fatalln(err) } defer lock.Release(ctx) - // FenceToken is 0 when the lock was obtained without Options.Fence. Stamp - // writes to the protected resource with the token; reject older ones. + // FenceToken is 0 without a FenceKey. Stamp writes with the token; reject older ones. if token := lock.FenceToken(); token != 0 { fmt.Printf("fenced write with token %d\n", token) } @@ -116,8 +115,11 @@ A few notes: the check (`reject if incoming < highest applied`) must happen atomically at the resource, which is the only place the comparison and the write can be made one operation. -- The token is stored at `:fence` (the first key, for multi-key locks) and - persists across release so it keeps increasing; it is never reset by `Release`. +- You supply `FenceKey`, so you control its placement. On Redis Cluster it must + hash to the same slot as the lock key(s) — share a `{hashtag}`, e.g. lock + `{job}:lock` with fence `{job}:fence` — or the script fails with `CROSSSLOT`. +- The counter persists across release so it keeps increasing; it is never reset + by `Release`. - The token is only as monotonic as the underlying Redis. On a single instance it is strict; on a Sentinel/Cluster failover that loses the `INCR`, it can regress. For strict cross-failover monotonicity, source the token from a linearizable diff --git a/README.md.tpl b/README.md.tpl index e237b71..21394cd 100644 --- a/README.md.tpl +++ b/README.md.tpl @@ -29,7 +29,7 @@ func main() {{ "Example" | code }} ### Fencing tokens -Set `Options.Fence` to mint a **fencing token** with the lock: a strictly +Set `Options.FenceKey` to mint a **fencing token** with the lock: a strictly increasing value, incremented atomically on each new acquisition and returned by `Lock.FenceToken`. Stamp every write to the protected resource with the token and have the resource reject any write carrying an older token. A lock holder that @@ -48,8 +48,11 @@ A few notes: the check (`reject if incoming < highest applied`) must happen atomically at the resource, which is the only place the comparison and the write can be made one operation. -- The token is stored at `:fence` (the first key, for multi-key locks) and - persists across release so it keeps increasing; it is never reset by `Release`. +- You supply `FenceKey`, so you control its placement. On Redis Cluster it must + hash to the same slot as the lock key(s) — share a `{hashtag}`, e.g. lock + `{job}:lock` with fence `{job}:fence` — or the script fails with `CROSSSLOT`. +- The counter persists across release so it keeps increasing; it is never reset + by `Release`. - The token is only as monotonic as the underlying Redis. On a single instance it is strict; on a Sentinel/Cluster failover that loses the `INCR`, it can regress. For strict cross-failover monotonicity, source the token from a linearizable diff --git a/example_test.go b/example_test.go index 091c125..ef5ab0c 100644 --- a/example_test.go +++ b/example_test.go @@ -98,14 +98,13 @@ func ExampleClient_Obtain_fence() { ctx := context.Background() // Obtain a lock with a fencing token. - lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{Fence: true}) + lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{FenceKey: "my-key:fence"}) if err != nil { log.Fatalln(err) } defer lock.Release(ctx) - // FenceToken is 0 when the lock was obtained without Options.Fence. Stamp - // writes to the protected resource with the token; reject older ones. + // FenceToken is 0 without a FenceKey. Stamp writes with the token; reject older ones. if token := lock.FenceToken(); token != 0 { fmt.Printf("fenced write with token %d\n", token) } diff --git a/obtain.lua b/obtain.lua index b938f65..4bafa46 100644 --- a/obtain.lua +++ b/obtain.lua @@ -1,36 +1,44 @@ --- obtain.lua: arguments => [value, tokenLen, ttl, fenceKey] +-- obtain.lua: arguments => [value, tokenLen, ttl, fenced] -- Obtain.lua try to set provided keys's with value and ttl if they do not exists. --- Keys can be overriden if they already exists and the correct value+tokenLen is provided. --- When fenceKey is set, returns a fencing token instead of "OK". +-- Keys can be overriden if they already exists and the correct value+tokenLen is provided. +-- When fenced is "1", the last KEYS entry is a fence key and obtain returns a +-- fencing token instead of "OK". + +local fenced = tonumber(ARGV[4]) == 1 + +-- Lock keys are KEYS[1..lockCount]; the fence key, if any, is the last entry. +local lockCount = #KEYS +if fenced then + lockCount = lockCount - 1 +end local function pexpire(ttl) -- Update keys ttls. - for _, key in ipairs(KEYS) do - redis.call("pexpire", key, ttl) + for i = 1, lockCount do + redis.call("pexpire", KEYS[i], ttl) end end -- canOverrideLock check either or not the provided token match -- previously set lock's tokens. -local function canOverrideKeys() +local function canOverrideKeys() local offset = tonumber(ARGV[2]) - for _, key in ipairs(KEYS) do - if redis.call("getrange", key, 0, offset-1) ~= string.sub(ARGV[1], 1, offset) then + for i = 1, lockCount do + if redis.call("getrange", KEYS[i], 0, offset-1) ~= string.sub(ARGV[1], 1, offset) then return false end end return true end -local fenceKey = ARGV[4] - -- reply returns the fencing token, advancing it only on a fresh acquisition, -- or "OK" when fencing is disabled. local function reply(fresh) - if fenceKey == nil or fenceKey == "" then + if not fenced then return redis.status_reply("OK") end + local fenceKey = KEYS[#KEYS] if fresh then return redis.call("incr", fenceKey) end @@ -39,8 +47,8 @@ end -- Prepare mset arguments. local setArgs = {} -for _, key in ipairs(KEYS) do - table.insert(setArgs, key) +for i = 1, lockCount do + table.insert(setArgs, KEYS[i]) table.insert(setArgs, ARGV[1]) end diff --git a/redislock.go b/redislock.go index 89e1dc0..aad76c1 100644 --- a/redislock.go +++ b/redislock.go @@ -81,11 +81,7 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati value := token + opt.getMetadata() ttlVal := strconv.FormatInt(int64(ttl/time.Millisecond), 10) - fence := opt.getFence() - fenceKey := "" - if fence { - fenceKey = keys[0] + ":fence" - } + fenceKey := opt.getFenceKey() var fenceToken int64 @@ -170,7 +166,19 @@ func withRetry(ctx context.Context, ttl time.Duration, retry RetryStrategy, atte } func (c *Client) obtain(ctx context.Context, keys []string, value string, tokenLen int, ttlVal, fenceKey string) (ok bool, fenceToken int64, err error) { - res, err := luaObtain.Run(ctx, c.client, keys, value, tokenLen, ttlVal, fenceKey).Result() + // The fence key, if any, goes in KEYS (so Cluster checks its slot) as the + // last entry; ARGV[4] flags its presence. + runKeys := keys + fence := 0 + + if fenceKey != "" { + runKeys = make([]string, 0, len(keys)+1) + runKeys = append(runKeys, keys...) + runKeys = append(runKeys, fenceKey) + fence = 1 + } + + res, err := luaObtain.Run(ctx, c.client, runKeys, value, tokenLen, ttlVal, fence).Result() if err != nil { if errors.Is(err, redis.Nil) { return false, 0, nil @@ -238,7 +246,7 @@ func (l *Lock) Token() string { } // FenceToken returns the lock's fencing token, or 0 if it was obtained without -// Options.Fence. Tokens start at 1, so 0 always means unfenced. +// Options.FenceKey. Tokens start at 1, so 0 always means unfenced. func (l *Lock) FenceToken() int64 { return l.fenceToken } @@ -320,10 +328,11 @@ type Options struct { // option to provide a custom token instead. Token string - // Fence enables a fencing token, minted on each new acquisition and - // returned by Lock.FenceToken. Stored at ":fence". - // Default: disabled. - Fence bool + // FenceKey enables a fencing token, minted at this key on each new + // acquisition and returned by Lock.FenceToken. On Redis Cluster it must hash + // to the same slot as the lock key(s). + // Default: empty, no fencing. + FenceKey string } func (o *Options) getMetadata() string { @@ -340,8 +349,11 @@ func (o *Options) getToken() string { return "" } -func (o *Options) getFence() bool { - return o != nil && o.Fence +func (o *Options) getFenceKey() string { + if o != nil { + return o.FenceKey + } + return "" } func (o *Options) getRetryStrategy() RetryStrategy { diff --git a/redislock_test.go b/redislock_test.go index 53f58b1..d6bbd67 100644 --- a/redislock_test.go +++ b/redislock_test.go @@ -116,20 +116,21 @@ func TestObtain_custom_token(t *testing.T) { func TestObtain_fence(t *testing.T) { rc := redisConnect(t) lockKey := rc.lockKey() - rc.keys = append(rc.keys, lockKey+":fence") + fenceKey := lockKey + ":fence" + rc.keys = append(rc.keys, fenceKey) - // no token without the Fence option + // no token without a FenceKey plain := quickObtain(t, time.Hour) defer plain.Release(t.Context()) if got := plain.FenceToken(); got != 0 { - t.Fatalf("expected no fence token without Options.Fence, got %v", got) + t.Fatalf("expected no fence token without Options.FenceKey, got %v", got) } if err := plain.Release(t.Context()); err != nil { t.Fatal(err) } // first fenced obtain - lock1, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Fence: true}) + lock1, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{FenceKey: fenceKey}) if err != nil { t.Fatal(err) } @@ -143,7 +144,7 @@ func TestObtain_fence(t *testing.T) { } // next obtain must mint a greater token - lock2, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Fence: true}) + lock2, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{FenceKey: fenceKey}) if err != nil { t.Fatal(err) } @@ -157,10 +158,11 @@ func TestObtain_fence(t *testing.T) { func TestObtain_fence_reentrant(t *testing.T) { rc := redisConnect(t) lockKey := rc.lockKey() - rc.keys = append(rc.keys, lockKey+":fence") + fenceKey := lockKey + ":fence" + rc.keys = append(rc.keys, fenceKey) // obtain with a known token - lock1, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Token: "foo", Fence: true}) + lock1, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Token: "foo", FenceKey: fenceKey}) if err != nil { t.Fatal(err) } @@ -169,7 +171,7 @@ func TestObtain_fence_reentrant(t *testing.T) { tok1 := lock1.FenceToken() // re-obtain (override) must not advance the token - lock2, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Token: "foo", Fence: true}) + lock2, err := Obtain(t.Context(), rc, lockKey, time.Hour, &Options{Token: "foo", FenceKey: fenceKey}) if err != nil { t.Fatal(err) }