-
Notifications
You must be signed in to change notification settings - Fork 169
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add typed cache-aside client implementation and tests (#717)
* 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
Showing
3 changed files
with
284 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} |