Skip to content

Commit

Permalink
feat: Add typed cache-aside client implementation and tests (#717)
Browse files Browse the repository at this point in the history
* feat: Add typed cache-aside client implementation and tests

Introduce `TypedCacheAsideClient` to support caching typed values, along with serialization/deserialization capabilities. Added corresponding tests to ensure functionality and updated README with usage examples.

* Remove error return from NewTypedCacheAsideClient.

The NewTypedCacheAsideClient function no longer returns an error, simplifying its usage. Updated relevant code and tests to align with this change by removing redundant error handling logic.

* Fix inconsistent indentation in README code example.

Aligned the deserializer function's indentation to match the rest of the code block. This improves readability and ensures a consistent coding style in the documentation.

* Update rueidisaside/typed_aside.go

Co-authored-by: Rueian <[email protected]>

* Update rueidisaside/typed_aside.go

Co-authored-by: Rueian <[email protected]>

* Fix deserializer to correctly handle nil cases.

Updated the deserializer function to ensure proper handling of nil cases during JSON unmarshalling. This prevents potential inconsistencies and improves reliability in processing deserialized values.

* Fix inconsistent indentation in code example

Corrected the indentation in the README code example for clarity and consistency. This improves readability and ensures proper code formatting.

* Remove detailed error messages from serializer/deserializer.

Including result details in error messages risks information leaks in logs. Users can handle error wrapping within their serializer/deserializer implementations if needed.

* Fix comparison logic in typed_aside_test.go validation

Updated validation checks to compare struct fields individually instead of entire structs, ensuring accurate error detection for mismatched field values. This improves test reliability and clarity by focusing on specific inconsistencies.

* Fix missing cache validation in Get test scenarios

Added a verification step to confirm proper caching behavior and ensured the retrieval function is called after the cache is deleted. This addresses potential issues with nil pointer dereference and test stability.

* Update tests to use dynamic keys instead of hardcoded values

Replaced hardcoded "test-key" with dynamically generated keys in tests using `randStr()`. This ensures test isolation, prevents potential key collisions, and improves reliability.

* Remove generic type parameter in example code.

* Fix typo in code example in README.md

* Update rueidisaside/README.md

Co-authored-by: Rueian <[email protected]>

* Refactor serializer/deserializer to simplify logic

---------

Co-authored-by: Rueian <[email protected]>
  • Loading branch information
korECM and rueian authored Jan 9, 2025
1 parent 5b8c32d commit 8897ec3
Show file tree
Hide file tree
Showing 3 changed files with 284 additions and 0 deletions.
54 changes: 54 additions & 0 deletions rueidisaside/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,60 @@ func main() {
}
```

If you want to use cache typed value, not string, you can use `rueidisaside.TypedCacheAsideClient`.

```go
package main

import (
"context"
"database/sql"
"encoding/json"
"time"

"github.com/redis/rueidis"
"github.com/redis/rueidis/rueidisaside"
)

type MyValue struct {
Val string `json:"val"`
}

func main() {
var db sql.DB
client, err := rueidisaside.NewClient(rueidisaside.ClientOption{
ClientOption: rueidis.ClientOption{InitAddress: []string{"127.0.0.1:6379"}},
})
if err != nil {
panic(err)
}

serializer := func(val *MyValue) (string, error) {
b, err := json.Marshal(val)
return string(b), err
}
deserializer := func(s string) (*MyValue, error) {
var val *MyValue
if err := json.Unmarshal([]byte(s), &val); err != nil {
return nil, err
}
return val, nil
}

typedClient := rueidisaside.NewTypedCacheAsideClient(client, serializer, deserializer)
val, err := typedClient.Get(context.Background(), time.Minute, "myKey", func(ctx context.Context, key string) (*MyValue, error) {
var val MyValue
if err := db.QueryRowContext(ctx, "SELECT val FROM mytab WHERE id = ?", key).Scan(&val.Val); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
return &val, nil
})
// ...
}
```

## Limitation

Currently, requires Redis >= 7.0.
65 changes: 65 additions & 0 deletions rueidisaside/typed_aside.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package rueidisaside

import (
"context"
"time"
)

// TypedCacheAsideClient is an interface that provides a typed cache-aside client.
// It allows you to cache and retrieve values of a specific type T.
type TypedCacheAsideClient[T any] interface {
Get(ctx context.Context, ttl time.Duration, key string, fn func(ctx context.Context, key string) (val *T, err error)) (val *T, err error)
Del(ctx context.Context, key string) error
Client() CacheAsideClient
}

// typedCacheAsideClient is an implementation of the TypedCacheAsideClient interface.
// It provides a typed cache-aside client that allows caching and retrieving values of a specific type T.
type typedCacheAsideClient[T any] struct {
client CacheAsideClient
serializer func(*T) (string, error)
deserializer func(string) (*T, error)
}

// NewTypedCacheAsideClient creates a new TypedCacheAsideClient instance that provides a typed cache-aside client.
// The client, serializer, and deserializer functions are used to interact with the underlying cache.
// The serializer function is used to convert the provided value of type T to a string, and the deserializer function
// is used to convert the cached string value back to the original type T.
func NewTypedCacheAsideClient[T any](
client CacheAsideClient,
serializer func(*T) (string, error),
deserializer func(string) (*T, error),
) TypedCacheAsideClient[T] {
return &typedCacheAsideClient[T]{
client: client,
serializer: serializer,
deserializer: deserializer,
}
}

// Get retrieves a value of type T from the cache, or fetches it using the provided
// function and stores it in the cache. The value is cached for the specified TTL.
// If the value cannot be retrieved or deserialized, an error is returned.
func (c typedCacheAsideClient[T]) Get(ctx context.Context, ttl time.Duration, key string, fn func(ctx context.Context, key string) (val *T, err error)) (val *T, err error) {
strVal, err := c.client.Get(ctx, ttl, key, func(ctx context.Context, key string) (val string, err error) {
result, err := fn(ctx, key)
if err != nil {
return "", err
}
return c.serializer(result)
})
if err != nil {
return nil, err
}
return c.deserializer(strVal)
}

// Del deletes the value associated with the given key from the cache.
func (c typedCacheAsideClient[T]) Del(ctx context.Context, key string) error {
return c.client.Del(ctx, key)
}

// Client returns the underlying CacheAsideClient instance used by the TypedCacheAsideClient.
func (c typedCacheAsideClient[T]) Client() CacheAsideClient {
return c.client
}
165 changes: 165 additions & 0 deletions rueidisaside/typed_aside_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package rueidisaside

import (
"context"
"encoding/json"
"errors"
"testing"
"time"

"github.com/redis/rueidis"
)

type testStruct struct {
ID int `json:"id"`
Name string `json:"name"`
}

func TestTypedCacheAsideClient_Get(t *testing.T) {
baseClient := makeClient(t, addr)
t.Cleanup(baseClient.Close)

serializer := func(v *testStruct) (string, error) {
if v == nil {
return "nilTestStruct", nil
}
b, err := json.Marshal(v)
return string(b), err
}
deserializer := func(s string) (*testStruct, error) {
if s == "nilTestStruct" {
return nil, nil
}
var v testStruct
err := json.Unmarshal([]byte(s), &v)
return &v, err
}

client := NewTypedCacheAsideClient[testStruct](baseClient, serializer, deserializer)

t.Run("successful get and cache", func(t *testing.T) {
expected := &testStruct{ID: 1, Name: "test"}
key := randStr()
val, err := client.Get(context.Background(), time.Second, key, func(ctx context.Context, key string) (*testStruct, error) {
return expected, nil
})

if err != nil {
t.Fatal(err)
}
if val.ID != expected.ID || val.Name != expected.Name {
t.Fatalf("expected %v, got %v", expected, val)
}

// Test cached value
val2, err := client.Get(context.Background(), time.Second, key, nil)
if err != nil {
t.Fatal(err)
}
if val.ID != expected.ID || val.Name != expected.Name {
t.Fatalf("cached value mismatch: expected %v, got %v", expected, val2)
}
})

t.Run("serialization error", func(t *testing.T) {
badSerializer := func(v *testStruct) (string, error) {
return "", errors.New("serialization error")
}
clientWithBadSerializer := NewTypedCacheAsideClient[testStruct](baseClient, badSerializer, deserializer)

key := randStr()
_, err := clientWithBadSerializer.Get(context.Background(), time.Second, key, func(ctx context.Context, key string) (*testStruct, error) {
return &testStruct{ID: 1, Name: "test"}, nil
})

if err == nil {
t.Fatal("expected serialization error")
}
})

t.Run("deserialization error", func(t *testing.T) {
badDeserializer := func(s string) (*testStruct, error) {
return nil, errors.New("deserialization error")
}
clientWithBadDeserializer := NewTypedCacheAsideClient[testStruct](baseClient, serializer, badDeserializer)

key := randStr()
_, err := clientWithBadDeserializer.Get(context.Background(), time.Second, key, func(ctx context.Context, key string) (*testStruct, error) {
return &testStruct{ID: 1, Name: "test"}, nil
})

if err == nil {
t.Fatal("expected deserialization error")
}
})

t.Run("nil value handling", func(t *testing.T) {
key := randStr()
val, err := client.Get(context.Background(), time.Second, key, func(ctx context.Context, key string) (*testStruct, error) {
return nil, nil
})

if err != nil {
t.Fatalf("nil valud should not return error: %v", err)
}
if val != nil {
t.Fatalf("expected nil value, got %v", val)
}
})
}

func TestTypedCacheAsideClient_Del(t *testing.T) {
baseClient := makeClient(t, addr)
t.Cleanup(baseClient.Close)

serializer := func(v *testStruct) (string, error) {
b, err := json.Marshal(v)
return string(b), err
}

deserializer := func(s string) (*testStruct, error) {
var v testStruct
err := json.Unmarshal([]byte(s), &v)
return &v, err
}

client := NewTypedCacheAsideClient[testStruct](baseClient, serializer, deserializer)

// Set a value first
key := randStr()
testVal := &testStruct{ID: 1, Name: "test"}
_, err := client.Get(context.Background(), time.Second, key, func(ctx context.Context, key string) (*testStruct, error) {
return testVal, nil
})
if err != nil {
t.Fatal(err)
}

// Verify it's cached
_, err = client.Get(context.Background(), time.Second, key, func(ctx context.Context, key string) (*testStruct, error) {
t.Fatal("this function should not be called because the value should be cached")
return testVal, nil
})
if err != nil {
t.Fatal(err)
}

// Delete the value
err = client.Del(context.Background(), key)
if err != nil {
t.Fatal(err)
}

// Verify it's deleted
called := false
_, err = client.Get(context.Background(), time.Second, key, func(ctx context.Context, key string) (val *testStruct, err error) {
called = true
return testVal, nil
})
if err != nil && !rueidis.IsRedisNil(err) {
t.Fatal("expected error for deleted key")
}
if !called {
t.Fatal("expected function to be called because the value should be deleted")
}
}

0 comments on commit 8897ec3

Please sign in to comment.