Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,40 @@ A few notes:
For strict cross-failover monotonicity, source the token from a linearizable
store instead.

### Release by token

`Lock.Release` needs the `*Lock` that `Obtain` returned, so release has to run in
the process that acquired the lock. To release from elsewhere — a worker that was
only handed the token — call `Client.ReleaseByToken` (or `ReleaseMultiByToken`)
with the key and the token from `Options.Token`:

```go
func releaseByToken() {
client := redis.NewClient(&redis.Options{Network: "tcp", Addr: "127.0.0.1:6379"})
defer client.Close()

locker := redislock.New(client)

ctx := context.Background()

// Obtain with a known token in one process, then hand the token off.
lock, err := locker.Obtain(ctx, "my-key", time.Minute, &redislock.Options{Token: "abc123"})
if err != nil {
log.Fatalln(err)
}
fmt.Println("holding", lock.Key())

// A different process, with only the key and token, can release it without
// the *Lock that Obtain returned.
if err := locker.ReleaseByToken(ctx, "my-key", "abc123"); err != nil {
log.Fatalln(err)
}
}
```

It matches on the token prefix alone, so `Metadata` set at acquisition is not
required, and it leaves any `FenceKey` counter untouched.

### External watchdog

`redislock` deliberately does not bundle a built-in watchdog goroutine: refresh
Expand Down
14 changes: 14 additions & 0 deletions README.md.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ A few notes:
For strict cross-failover monotonicity, source the token from a linearizable
store instead.

### Release by token

`Lock.Release` needs the `*Lock` that `Obtain` returned, so release has to run in
the process that acquired the lock. To release from elsewhere — a worker that was
only handed the token — call `Client.ReleaseByToken` (or `ReleaseMultiByToken`)
with the key and the token from `Options.Token`:

```go
func releaseByToken() {{ "ExampleClient_ReleaseByToken" | code }}
```

It matches on the token prefix alone, so `Metadata` set at acquisition is not
required, and it leaves any `FenceKey` counter untouched.

### External watchdog

`redislock` deliberately does not bundle a built-in watchdog goroutine: refresh
Expand Down
22 changes: 22 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,25 @@ func ExampleClient_Obtain_customDeadline() {

fmt.Println("I have a lock!")
}

func ExampleClient_ReleaseByToken() {
client := redis.NewClient(&redis.Options{Network: "tcp", Addr: "127.0.0.1:6379"})
defer client.Close()

locker := redislock.New(client)

ctx := context.Background()

// Obtain with a known token in one process, then hand the token off.
lock, err := locker.Obtain(ctx, "my-key", time.Minute, &redislock.Options{Token: "abc123"})
if err != nil {
log.Fatalln(err)
}
fmt.Println("holding", lock.Key())

// A different process, with only the key and token, can release it without
// the *Lock that Obtain returned.
if err := locker.ReleaseByToken(ctx, "my-key", "abc123"); err != nil {
log.Fatalln(err)
}
}
45 changes: 41 additions & 4 deletions redislock.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ var luaPTTLScript string
//go:embed obtain.lua
var luaObtainScript string

//go:embed release_token.lua
var luaReleaseTokenScript string

var (
luaRefresh = redis.NewScript(luaRefreshScript)
luaRelease = redis.NewScript(luaReleaseScript)
luaPTTL = redis.NewScript(luaPTTLScript)
luaObtain = redis.NewScript(luaObtainScript)
luaRefresh = redis.NewScript(luaRefreshScript)
luaRelease = redis.NewScript(luaReleaseScript)
luaPTTL = redis.NewScript(luaPTTLScript)
luaObtain = redis.NewScript(luaObtainScript)
luaReleaseToken = redis.NewScript(luaReleaseTokenScript)
)

var (
Expand Down Expand Up @@ -105,6 +109,29 @@ func (c *Client) ObtainMulti(ctx context.Context, keys []string, ttl time.Durati
return &Lock{Client: c, keys: keys, value: value, tokenLen: len(token), fenceToken: fenceToken}, nil
}

// ReleaseByToken releases the lock on key, identified by its token, without the
// *Lock that Obtain returned. Use it when acquire and release run in different
// processes and only the token was carried across. It matches on the token
// alone, so Metadata need not have been set, and like Lock.Release it leaves
// any FenceKey counter untouched.
// May return ErrLockNotHeld.
func (c *Client) ReleaseByToken(ctx context.Context, key, token string) error {
return c.ReleaseMultiByToken(ctx, []string{key}, token)
}

// ReleaseMultiByToken is the multi-key form of ReleaseByToken.
// May return ErrLockNotHeld.
func (c *Client) ReleaseMultiByToken(ctx context.Context, keys []string, token string) error {
_, err := luaReleaseToken.Run(ctx, c.client, keys, token).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return ErrLockNotHeld
}
return err
}
return nil
}

// withRetry runs attempt repeatedly until it signals it is done, the retry
// strategy is exhausted, or ctx is done. The attempt function returns
// (done, err):
Expand Down Expand Up @@ -229,6 +256,16 @@ func ObtainMulti(ctx context.Context, client RedisClient, keys []string, ttl tim
return New(client).ObtainMulti(ctx, keys, ttl, opt)
}

// ReleaseByToken is a short-cut for New(...).ReleaseByToken(...).
func ReleaseByToken(ctx context.Context, client RedisClient, key, token string) error {
return New(client).ReleaseByToken(ctx, key, token)
}

// ReleaseMultiByToken is a short-cut for New(...).ReleaseMultiByToken(...).
func ReleaseMultiByToken(ctx context.Context, client RedisClient, keys []string, token string) error {
return New(client).ReleaseMultiByToken(ctx, keys, token)
}

// Key returns the redis key used by the lock.
// If the lock hold multiple key, only the first is returned.
func (l *Lock) Key() string {
Expand Down
78 changes: 78 additions & 0 deletions redislock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,84 @@ func TestLock_Release_not_held(t *testing.T) {
}
}

func TestClient_ReleaseByToken(t *testing.T) {
rc := redisConnect(t)
lockKey := rc.lockKey()
client := New(rc)

lock, err := client.Obtain(t.Context(), lockKey, time.Hour, nil)
if err != nil {
t.Fatal(err)
}

// release from just the key and token, without the *Lock
if err := client.ReleaseByToken(t.Context(), lockKey, lock.Token()); err != nil {
t.Fatal(err)
}

// the key is free again
lock2, err := client.Obtain(t.Context(), lockKey, time.Hour, nil)
if err != nil {
t.Fatal(err)
}
defer lock2.Release(t.Context())
}

func TestClient_ReleaseByToken_with_metadata(t *testing.T) {
rc := redisConnect(t)
lockKey := rc.lockKey()
client := New(rc)

// the stored value is token+metadata
lock, err := client.Obtain(t.Context(), lockKey, time.Hour, &Options{Token: "tok", Metadata: "meta"})
if err != nil {
t.Fatal(err)
}

// releasing by the token alone must clear it, without knowing the metadata
if err := client.ReleaseByToken(t.Context(), lockKey, lock.Token()); err != nil {
t.Fatal(err)
}

lock2, err := client.Obtain(t.Context(), lockKey, time.Hour, nil)
if err != nil {
t.Fatal(err)
}
defer lock2.Release(t.Context())
}

func TestClient_ReleaseByToken_wrong_token(t *testing.T) {
rc := redisConnect(t)
lockKey := rc.lockKey()
client := New(rc)

lock, err := client.Obtain(t.Context(), lockKey, time.Hour, &Options{Token: "mine"})
if err != nil {
t.Fatal(err)
}
defer lock.Release(t.Context())

// a wrong token must not release someone else's lock
if exp, got := ErrLockNotHeld, client.ReleaseByToken(t.Context(), lockKey, "theirs"); !errors.Is(got, exp) {
t.Fatalf("expected %v, got %v", exp, got)
}

// the lock is still held, so the real owner can still release it
if err := lock.Release(t.Context()); err != nil {
t.Fatal(err)
}
}

func TestClient_ReleaseByToken_not_held(t *testing.T) {
rc := redisConnect(t)
client := New(rc)

err := client.ReleaseByToken(t.Context(), rc.lockKey(), "nonexistent")
if exp, got := ErrLockNotHeld, err; !errors.Is(got, exp) {
t.Fatalf("expected %v, got %v", exp, got)
}
}

func TestLock_ObtainMulti(t *testing.T) {
rc := redisConnect(t)
lockKey1 := rc.lockKey("_MultiLock_1")
Expand Down
18 changes: 18 additions & 0 deletions release_token.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- release_token.lua: => Arguments: [token]
-- release_token.lua deletes provided keys if their current value is prefixed
-- by the given token, ignoring any metadata suffix. Unlike release.lua, the
-- caller does not need to know the original value (token+metadata).

local offset = #ARGV[1]

-- Check all keys' values are prefixed with the provided token.
for _, key in ipairs(KEYS) do
if redis.call("getrange", key, 0, offset-1) ~= ARGV[1] then
return false
end
end

-- Delete keys.
redis.call("del", unpack(KEYS))

return redis.status_reply("OK")