-
Notifications
You must be signed in to change notification settings - Fork 250
feat: redis adapter for entity caching cache #3139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+976
−6
Open
Changes from 11 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
1df9830
feat: redis adapter
SkArchon 59ed4a9
feat: in memory adapter
SkArchon f82ddc0
fix: tests
SkArchon e1edf72
fix: updates
SkArchon f146a8f
fix: update in memory logic and tests
SkArchon 751fd60
fix: gofmt
SkArchon 4b92dc0
fix: review comments
SkArchon d98a8d2
feat: redis adapter reports remaining TTL and known stored keys
SkArchon d7a6a14
fix: updates
SkArchon ab0d40a
fix: review comments
SkArchon c091feb
fix: review comments
SkArchon 64fcca2
fix: review comments
SkArchon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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,153 @@ | ||
| package redis | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
|
|
||
| "github.com/redis/go-redis/v9" | ||
| enginecache "github.com/wundergraph/graphql-go-tools/v2/pkg/entitycaching" | ||
| ) | ||
|
|
||
| // RedisCache stores entries in Redis. | ||
| type RedisCache struct { | ||
| // client is owned by the caller, not this cache, so it is never closed | ||
| // here. It is a UniversalClient so a single, cluster or sentinel client all | ||
| // fit without this cache having to know which one it got. | ||
| client redis.UniversalClient | ||
| // prefix is prepended to every key before it reaches redis, so entity cache | ||
| // entries stay in their own namespace and cannot collide with anything else | ||
| // sharing the instance. It is applied on the way in and stripped back off on | ||
| // the way out, so callers only ever see the keys they asked with. An empty | ||
| // prefix is valid and means the keys are used as they are. | ||
| prefix string | ||
| } | ||
|
|
||
| // NewRedisCache returns a cache backed by client, namespacing every key with | ||
| // prefix. The caller keeps ownership of client and is responsible for closing | ||
| // it. rediscloser.RDCloser satisfies redis.UniversalClient, so a client built | ||
| // by rediscloser.NewRedisCloser can be passed straight in. | ||
| func NewRedisCache(client redis.UniversalClient, prefix string) (*RedisCache, error) { | ||
| if client == nil { | ||
| return nil, errors.New("redis client is nil") | ||
| } | ||
|
|
||
| return &RedisCache{client: client, prefix: prefix}, nil | ||
| } | ||
|
|
||
| // GetMany returns one result per key, in the same order as keys. | ||
| func (c *RedisCache) GetMany(ctx context.Context, keys []string) (map[string]enginecache.Item, error) { | ||
| if len(keys) == 0 { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // A pipeline of GETs rather than a single MGET: go-redis splits a pipeline | ||
| // across cluster nodes, while MGET fails with CROSSSLOT as soon as the keys | ||
| // span slots. | ||
| // | ||
| // Each key costs a second command, a PTTL, because a GET only hands back | ||
| // the value and the lifetime it has left has to be asked for separately. | ||
| // That doubles the commands but not the round trips, the pipeline is still | ||
| // one write and one read, and a PTTL is O(1) server side. The pair is | ||
| // queued together so the window in which the key can expire between the two | ||
| // stays one command wide, but they are still not atomic and the loop below | ||
| // is written to survive that. | ||
| pipe := c.client.Pipeline() | ||
| values := make([]*redis.StringCmd, len(keys)) | ||
| ttls := make([]*redis.DurationCmd, len(keys)) | ||
| for i, key := range keys { | ||
| prefixed := c.prefix + key | ||
| values[i] = pipe.Get(ctx, prefixed) | ||
| ttls[i] = pipe.PTTL(ctx, prefixed) | ||
| } | ||
|
|
||
| // A miss surfaces as redis.Nil, which is not a failure of the batch. A PTTL | ||
| // never reports a missing key that way, it answers with a negative | ||
| // duration, so every redis.Nil in here came from a GET. | ||
| _, err := pipe.Exec(ctx) | ||
| if err != nil && !errors.Is(err, redis.Nil) { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Sized for every key finding something, which is the case worth being | ||
| // ready for. A miss adds nothing, so the map is only as big as the hits. | ||
| results := make(map[string]enginecache.Item, len(keys)) | ||
| for i, key := range keys { | ||
| value, err := values[i].Bytes() | ||
| if errors.Is(err, redis.Nil) { | ||
| continue | ||
| } | ||
| if err != nil { | ||
| // There is no partial read to salvage, the whole batch fails. | ||
| return nil, fmt.Errorf("get %q: %w", key, err) | ||
| } | ||
|
|
||
| ttl, err := ttls[i].Result() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("pttl %q: %w", key, err) | ||
| } | ||
|
|
||
| if ttl <= 0 { | ||
| continue | ||
| } | ||
|
|
||
| // Keyed by what the caller asked with, not the prefixed key it was | ||
| // stored under: the namespace is this cache's business, not theirs. | ||
| results[key] = enginecache.Item{Key: key, Value: bytes.Clone(value), TTL: ttl} | ||
| } | ||
|
|
||
| return results, nil | ||
| } | ||
|
|
||
| // SetMany stores every item, all of which must carry a positive TTL. A single | ||
| // item without one fails the whole batch with an ErrMissingTTL and nothing is | ||
| // written. | ||
| // | ||
| // Any other failure can leave the batch applied in part, since redis runs each | ||
| // command in a pipeline as it arrives. When some of it was confirmed written | ||
| // the error is a *SetManyError naming those keys. | ||
| func (c *RedisCache) SetMany(ctx context.Context, items []enginecache.Item) error { | ||
| if len(items) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| // Queuing writes nothing to redis, only Exec below does, so validating as | ||
| // we go is enough: returning early abandons the whole pipeline unsent, and | ||
| // a batch rejected for a missing TTL is the one case where nothing at all | ||
| // was written. | ||
| // | ||
| // Each command is kept alongside the item that queued it, rather than read | ||
| // back off Exec, so which key a reply belongs to is not a question of the | ||
| // two orders still agreeing. | ||
| pipe := c.client.Pipeline() | ||
| cmds := make([]*redis.StatusCmd, len(items)) | ||
| for i, item := range items { | ||
| if item.TTL <= 0 { | ||
| return fmt.Errorf("%w: key %q", enginecache.ErrMissingTTL, item.Key) | ||
| } | ||
| cmds[i] = pipe.Set(ctx, c.prefix+item.Key, item.Value, item.TTL) | ||
| } | ||
|
|
||
| _, err := pipe.Exec(ctx) | ||
| if err == nil { | ||
| return nil | ||
| } | ||
|
|
||
| // A command is only counted once redis has answered it. Anything still | ||
| // carrying the failure is left out, whether it never arrived or was applied | ||
| // and lost its reply on the way back, so this understates what was written | ||
| // rather than claiming a key that might not be there. | ||
| var stored []string | ||
| for i, cmd := range cmds { | ||
| if cmd.Err() == nil { | ||
| stored = append(stored, items[i].Key) | ||
| } | ||
| } | ||
|
|
||
| if len(stored) == 0 { | ||
| return err | ||
| } | ||
|
|
||
| return &enginecache.SetManyError{KnownStoredKeys: stored, Err: err} | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the
GetManydoc comment.GetManyreturns amap[string]enginecache.Item. A map has no order, and misses and expired entries are omitted, so there is not one result per key. Describe the actual contract.The field comment at lines 21-22 has the same problem. The prefix is never stripped. Results are keyed by the caller key, which was never prefixed.
📝 Proposed doc fix
🤖 Prompt for AI Agents