From 355fe05f038a36c0390ec1a769bb1b20b7ec1e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Sun, 12 Jul 2026 01:07:42 +0300 Subject: [PATCH] Add Client.ReleaseByToken for cross-process lock release --- README.md | 34 +++++++++++++++++++++ README.md.tpl | 14 +++++++++ example_test.go | 22 +++++++++++++ redislock.go | 45 ++++++++++++++++++++++++--- redislock_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++ release_token.lua | 18 +++++++++++ 6 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 release_token.lua diff --git a/README.md b/README.md index c2cc2c7..c161e56 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.md.tpl b/README.md.tpl index 21394cd..47a92f6 100644 --- a/README.md.tpl +++ b/README.md.tpl @@ -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 diff --git a/example_test.go b/example_test.go index ef5ab0c..430c3ce 100644 --- a/example_test.go +++ b/example_test.go @@ -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) + } +} diff --git a/redislock.go b/redislock.go index aad76c1..80afbb0 100644 --- a/redislock.go +++ b/redislock.go @@ -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 ( @@ -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): @@ -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 { diff --git a/redislock_test.go b/redislock_test.go index d6bbd67..da01718 100644 --- a/redislock_test.go +++ b/redislock_test.go @@ -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") diff --git a/release_token.lua b/release_token.lua new file mode 100644 index 0000000..eebd54d --- /dev/null +++ b/release_token.lua @@ -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") \ No newline at end of file