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
6 changes: 3 additions & 3 deletions rueidisaside/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ func main() {

## Limitation

Currently, requires Redis >= 7.0.
However, the `UseLuaLock` option is available and allows you to use the `rueidisaside` with older Redis versions < 7.0 as well.
By default, lock acquisition uses the Redis 7.0 `SET NX GET` behavior.
The `UseLuaLock` option switches the acquisition step to a Lua implementation that is compatible with older Redis versions < 7.0.

To configure the Lua fallback option:

Expand All @@ -120,7 +120,7 @@ client, err := rueidisaside.NewClient(rueidisaside.ClientOption{
ClientOption: rueidis.ClientOption{
InitAddress: []string{"127.0.0.1:6379"},
},
UseLuaLock: true, // Enable Lua script for older Redis versions
UseLuaLock: true, // Enable the Redis < 7 compatible lock implementation
})
if err != nil {
panic(err)
Expand Down
35 changes: 15 additions & 20 deletions rueidisaside/aside.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/binary"
"encoding/hex"
"math/rand"
"strconv"
"strings"
"sync"
"time"
Expand All @@ -19,7 +18,7 @@ type ClientOption struct {
ClientBuilder func(option rueidis.ClientOption) (rueidis.Client, error)
ClientOption rueidis.ClientOption
ClientTTL time.Duration // TTL for the client marker, refreshed every 1/2 TTL. Defaults to 10s. The marker allows other clients to know if this client is still alive.
UseLuaLock bool
UseLuaLock bool // Use the Redis < 7 compatible lock implementation.
}

type CacheAsideClient interface {
Expand All @@ -35,9 +34,11 @@ func NewClient(option ClientOption) (cc CacheAsideClient, err error) {
}
ca := &Client{
waits: make(map[string]chan struct{}),
flights: make(map[string]*flight),
ttl: option.ClientTTL,
useLuaLock: option.UseLuaLock,
}
option.ClientOption.PipelineMultiplex = -1 // ensure lock and cleanup commands use the same connection.
option.ClientOption.OnInvalidations = ca.onInvalidation
if option.ClientBuilder != nil {
ca.client, err = option.ClientBuilder(option.ClientOption)
Expand All @@ -55,6 +56,7 @@ type Client struct {
client rueidis.Client
ctx context.Context
waits map[string]chan struct{}
flights map[string]*flight
cancel context.CancelFunc
id string
ttl time.Duration
Expand Down Expand Up @@ -157,25 +159,18 @@ retry:
val, err := resp.ToString()

if rueidis.IsRedisNil(err) && fn != nil { // cache miss, prepare to populate the value by fn()
var id string
if id, err = c.keepalive(); err == nil { // acquire client id
if c.useLuaLock {
val, err = acquireLock.Exec(ctx, c.client, []string{key}, []string{id, strconv.FormatInt(ttl.Milliseconds(), 10)}).ToString()
} else {
val, err = c.client.Do(ctx, c.client.B().Set().Key(key).Value(id).Nx().Get().Px(ttl).Build()).ToString()
}

if rueidis.IsRedisNil(err) { // successfully set client id on the key as a lock
// attach TTL pointer to context for potential modification via OverrideCacheTTL
ctx = context.WithValue(ctx, ttlKey, &ttl)
if val, err = fn(ctx, key); err == nil {
err = setkey.Exec(ctx, c.client, []string{key}, []string{id, val, strconv.FormatInt(ttl.Milliseconds(), 10)}).Error()
}
if err != nil { // failed to populate the value, release the lock.
delkey.Exec(context.Background(), c.client, []string{key}, []string{id})
}
f, leader := c.beginFlight(key)
if !leader {
select {
case <-f.done:
goto retry

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if the client id changed here, why can't we just wait for the flight to finish?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we jump to retry, we will have a busy retry loop until the existing flight acquires the lock, which is not that good.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — we can wait for the existing per-key flight here. Fixed in 81fe858.

The flight map is now keyed strictly by cache key, and beginFlight looks up an active flight before validating the client ID. Active flights are no longer reset on invalidation, so a caller that observed an old or new client ID joins the same in-process flow and waits with its own context instead of repeatedly jumping to retry.

The client-ID check remains only when no flight exists, which prevents a stale caller from becoming a new leader. In that narrow case one retry is still needed to obtain the current ID.

I also updated the Redis 7 and legacy disconnect tests to verify that an ID change does not start a second loader while the old flight is active, and added a unit regression for joining a flight across generations. go test, go test -race, and go vet pass.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up in 1854a98: I removed the remaining stale-ID/no-flight retry as well. The per-key flight is now created before keepalive, so the whole local miss flow starts with strict key-based singleflight. The leader resolves the current client ID inside that flight, while every same-key follower waits with its own context. This removes the retry branch entirely and still keeps different keys independent.

A new regression test blocks client-marker creation and verifies that a follower neither creates another marker nor runs another loader. Full tests, race tests, and go vet pass.

Comment thread
cursor[bot] marked this conversation as resolved.
case <-ctx.Done():
return "", ctx.Err()
case <-c.ctx.Done():
return "", c.ctx.Err()
}
}
val, err = c.populate(ctx, ttl, key, fn, f)
}

if err != nil {
Expand Down Expand Up @@ -220,7 +215,7 @@ func (c *Client) Close() {
id := c.id
c.mu.Unlock()
if id != "" {
c.client.Do(context.Background(), c.client.B().Del().Key(c.id).Build())
c.client.Do(context.Background(), c.client.B().Del().Key(id).Build())
}
c.client.Close()
}
Expand Down
168 changes: 86 additions & 82 deletions rueidisaside/aside_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package rueidisaside

import (
"context"
"errors"
"math/rand"
"strconv"
"sync"
Expand Down Expand Up @@ -45,9 +46,11 @@ func TestClientErr(t *testing.T) {

func TestWithClientBuilder(t *testing.T) {
var client rueidis.Client
var pipelineMultiplex int
c, err := NewClient(ClientOption{
ClientOption: rueidis.ClientOption{InitAddress: addr, SelectDB: 5},
ClientOption: rueidis.ClientOption{InitAddress: addr, PipelineMultiplex: 3, SelectDB: 5},
ClientBuilder: func(option rueidis.ClientOption) (_ rueidis.Client, err error) {
pipelineMultiplex = option.PipelineMultiplex
client, err = rueidis.NewClient(option)
return client, err
},
Expand All @@ -59,6 +62,9 @@ func TestWithClientBuilder(t *testing.T) {
if c.Client() != client {
t.Fatal("client mismatched")
}
if pipelineMultiplex != -1 {
t.Fatalf("expected PipelineMultiplex -1, got %d", pipelineMultiplex)
}
}

func TestCacheFilled(t *testing.T) {
Expand Down Expand Up @@ -341,102 +347,100 @@ func TestTimeoutLL(t *testing.T) {

func TestDisconnect(t *testing.T) {
client := makeClient(t, addr).(*Client)
testDisconnectWaitsForFlight(t, client)
}

func TestDisconnectLL(t *testing.T) {
client := makeClientWithLuaLock(t, addr).(*Client)
testDisconnectWaitsForFlight(t, client)
}

func testDisconnectWaitsForFlight(t *testing.T, client *Client) {
t.Helper()
defer client.Close()
key := strconv.Itoa(rand.Int())
ch := make(chan string, 2)
val, err := client.Get(context.Background(), time.Second*5, key, func(ctx context.Context, key string) (val string, err error) {
id1, err := client.client.Do(context.Background(), client.client.B().Get().Key(key).Build()).ToString()
if err != nil {
t.Error(err)
}
go func() {
val, err := client.Get(context.Background(), time.Second*5, key, func(ctx context.Context, key string) (val string, err error) {
id2, err := client.client.Do(context.Background(), client.client.B().Get().Key(key).Build()).ToString()
if err != nil {
t.Error(err)
}
ch <- id2
return "2", nil
})
if val != "2" {
t.Error(err)
}
}()
client.onInvalidation(nil) // simulate disconnection
id2 := <-ch
if id1 == id2 {
t.Error("id not changed")
}
ch <- id1
ch <- id2
return "1", nil
})
if val != "1" {
t.Fatal(err)
defer client.client.Do(context.Background(), client.client.B().Del().Key(key).Build())

leaderErr := errors.New("leader stopped after disconnect")
leaderReady := make(chan string, 1)
leaderRelease := make(chan struct{})
var releaseOnce sync.Once
releaseLeader := func() {
releaseOnce.Do(func() { close(leaderRelease) })
}
val, err = client.Get(context.Background(), time.Millisecond*500, key, nil)
if val != "2" {
t.Error(err)
defer releaseLeader()

leaderResult := make(chan getResult, 1)
go func() {
val, err := client.Get(context.Background(), time.Second*5, key, func(ctx context.Context, key string) (string, error) {
id1, err := client.client.Do(context.Background(), client.client.B().Get().Key(key).Build()).ToString()
if err != nil {
return "", err
}
client.onInvalidation(nil) // simulate disconnection
leaderReady <- id1
<-leaderRelease
return "", leaderErr
})
leaderResult <- getResult{val: val, err: err}
}()

var id1 string
select {
case id1 = <-leaderReady:
case <-time.After(time.Second):
t.Fatal("leader did not reach the loader")
}
err = client.client.Do(context.Background(), client.client.B().Get().Key(<-ch).Build()).Error() // id1
if !rueidis.IsRedisNil(err) {
t.Error(err)

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
var followerLoaderCalled atomic.Bool
_, err := client.Get(ctx, time.Second*5, key, func(context.Context, string) (string, error) {
followerLoaderCalled.Store(true)
return "unexpected", nil
})
cancel()
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected follower to wait for the active flight, got %v", err)
}
err = client.client.Do(context.Background(), client.client.B().Get().Key(<-ch).Build()).Error() // id2
if err != nil {
t.Error(err)
if followerLoaderCalled.Load() {
t.Fatal("follower started another loader while the old generation was active")
}
time.Sleep(client.ttl) // wait old refresh goroutine exit
}

func TestDisconnectLL(t *testing.T) {
client := makeClientWithLuaLock(t, addr).(*Client)
defer client.Close()
key := strconv.Itoa(rand.Int())
ch := make(chan string, 2)
val, err := client.Get(context.Background(), time.Second*5, key, func(ctx context.Context, key string) (val string, err error) {
id1, err := client.client.Do(context.Background(), client.client.B().Get().Key(key).Build()).ToString()
if err != nil {
t.Error(err)
releaseLeader()
select {
case result := <-leaderResult:
if !errors.Is(result.err, leaderErr) {
t.Fatalf("expected leader error, got %q, %v", result.val, result.err)
}
go func() {
val, err := client.Get(context.Background(), time.Second*5, key, func(ctx context.Context, key string) (val string, err error) {
id2, err := client.client.Do(context.Background(), client.client.B().Get().Key(key).Build()).ToString()
if err != nil {
t.Error(err)
}
ch <- id2
return "2", nil
})
if val != "2" {
t.Error(err)
}
}()
client.onInvalidation(nil) // simulate disconnection
id2 := <-ch
if id1 == id2 {
t.Error("id not changed")
}
ch <- id1
ch <- id2
return "1", nil
case <-time.After(time.Second):
t.Fatal("leader did not finish")
}

var id2 string
val, err := client.Get(context.Background(), time.Second*5, key, func(context.Context, string) (string, error) {
var err error
id2, err = client.client.Do(context.Background(), client.client.B().Get().Key(key).Build()).ToString()
return "2", err
})
if val != "1" {
t.Fatal(err)
if err != nil || val != "2" {
t.Fatalf("new generation did not populate the cache: %q, %v", val, err)
}
val, err = client.Get(context.Background(), time.Millisecond*500, key, nil)
if val != "2" {
t.Error(err)
if id1 == id2 {
t.Fatal("client id did not change")
}

val, err = client.client.Do(context.Background(), client.client.B().Get().Key(key).Build()).ToString()
if err != nil || val != "2" {
t.Fatalf("unexpected cache value: %q, %v", val, err)
}
err = client.client.Do(context.Background(), client.client.B().Get().Key(<-ch).Build()).Error() // id1
err = client.client.Do(context.Background(), client.client.B().Get().Key(id1).Build()).Error()
if !rueidis.IsRedisNil(err) {
t.Error(err)
t.Fatalf("old client marker still exists: %v", err)
}
err = client.client.Do(context.Background(), client.client.B().Get().Key(<-ch).Build()).Error() // id2
err = client.client.Do(context.Background(), client.client.B().Get().Key(id2).Build()).Error()
if err != nil {
t.Error(err)
t.Fatalf("new client marker is missing: %v", err)
}
time.Sleep(client.ttl) // wait old refresh goroutine exit
}

func TestMultipleClient(t *testing.T) {
Expand Down
79 changes: 79 additions & 0 deletions rueidisaside/flight.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package rueidisaside

import (
"context"
"strconv"
"time"

"github.com/redis/rueidis"
)

type flight struct {
done chan struct{}
}

func (c *Client) beginFlight(key string) (f *flight, leader bool) {
c.mu.Lock()
defer c.mu.Unlock()

if f = c.flights[key]; f != nil {
return f, false
}
f = &flight{done: make(chan struct{})}
c.flights[key] = f
return f, true
}

func (c *Client) finishFlight(key string, f *flight) {
c.mu.Lock()
defer c.mu.Unlock()

if c.flights[key] == f {
delete(c.flights, key)
close(f.done)
}
}

func (c *Client) populate(
ctx context.Context,
ttl time.Duration,
key string,
fn func(ctx context.Context, key string) (val string, err error),
f *flight,
) (val string, err error) {
defer c.finishFlight(key, f)

id, err := c.keepalive()
if err != nil {
return "", err
}

cleanup := true
defer func() {
if cleanup {
delkey.Exec(context.Background(), c.client, []string{key}, []string{id})
}
}()

if c.useLuaLock {
val, err = acquireLock.Exec(ctx, c.client, []string{key}, []string{id, strconv.FormatInt(ttl.Milliseconds(), 10)}).ToString()
} else {
val, err = c.client.Do(ctx, c.client.B().Set().Key(key).Value(id).Nx().Get().Px(ttl).Build()).ToString()
}
if err == nil {
cleanup = false
return val, nil
}
if !rueidis.IsRedisNil(err) {
return val, err
}

ctx = context.WithValue(ctx, ttlKey, &ttl)
if val, err = fn(ctx, key); err == nil {
err = setkey.Exec(ctx, c.client, []string{key}, []string{id, val, strconv.FormatInt(ttl.Milliseconds(), 10)}).Error()
}
if err == nil {
cleanup = false
}
return val, err
}
Loading
Loading