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
60 changes: 60 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"math/rand"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -254,6 +255,65 @@ func (cr *cache) delete(ctx context.Context, key string) error {
return err
}

func (cr *cache) deleteMany(ctx context.Context, keys []string) error {
if cr.amnesiaChance == 100 {
return errors.New("Had Amnesia")
}
if len(keys) == 0 {
return nil
}
if cr.syncmap != nil {
for _, key := range keys {
cr.syncmap.Delete(key)
}
return nil
} else if cr.inMemCache != nil {
var errs []string
for _, key := range keys {
if err := cr.inMemCache.Delete(key); err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", key, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("cache delete errors: %s", strings.Join(errs, "; "))
}
return nil
}
client := cr.baseRedisClient
_, err := client.Pipelined(ctx, func(pipe redis.Pipeliner) error {
for _, key := range keys {
pipe.Del(ctx, key)
}
return nil
})
return err
}

func (cr *cache) scan(ctx context.Context, pattern string, count int64) ([]string, error) {
if cr.amnesiaChance == 100 {
return nil, errors.New("Had Amnesia")
}
if count <= 0 {
count = 100
}
if cr.baseRedisClient == nil {
return nil, fmt.Errorf("scan is only supported for redis-backed cache layers")
}

client := cr.baseRedisClient
iter := client.Scan(ctx, 0, pattern, count).Iterator()

var matchedKeys []string
for iter.Next(ctx) {
matchedKeys = append(matchedKeys, iter.Val())
}
if err := iter.Err(); err != nil {
return nil, err
}

return matchedKeys, nil
}

func (cr *cache) clear() error {
if cr.amnesiaChance == 100 {
return errors.New("Had Amnesia")
Expand Down
71 changes: 68 additions & 3 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type MnemosyneInstance struct {
// NewMnemosyne initializes the Mnemosyne object which holds all cache instances
func NewMnemosyne(config *viper.Viper, commTimer ITimer, cacheHitCounter ICounter) *Mnemosyne {
if config == nil {
logrus.Panicf("%w: nil config", ErrInvalidConfig)
logrus.Panicf("%v: nil config", ErrInvalidConfig)
}

if commTimer == nil {
Expand All @@ -41,7 +41,7 @@ func NewMnemosyne(config *viper.Viper, commTimer ITimer, cacheHitCounter ICounte

cacheConfigs := config.GetStringMap("cache")
if len(cacheConfigs) == 0 {
logrus.Panicf("%w: no cache configurations found", ErrInvalidConfig)
logrus.Panicf("%v: no cache configurations found", ErrInvalidConfig)
}

caches := make(map[string]*MnemosyneInstance, len(cacheConfigs))
Expand All @@ -60,7 +60,7 @@ func NewMnemosyne(config *viper.Viper, commTimer ITimer, cacheHitCounter ICounte
}

if len(caches) == 0 {
logrus.Panicf("%w: no valid cache instances created", ErrInvalidConfig)
logrus.Panicf("%v: no valid cache instances created", ErrInvalidConfig)
}

return &Mnemosyne{
Expand Down Expand Up @@ -247,6 +247,71 @@ func (mn *MnemosyneInstance) Delete(ctx context.Context, key string) error {
return nil
}

// Scan returns all keys that match a Redis glob pattern across the Redis-backed layers.
func (mn *MnemosyneInstance) Scan(ctx context.Context, pattern string, count int64) ([]string, error) {
matched := make([]string, 0)
seen := make(map[string]struct{})
foundRedisLayer := false

for _, layer := range mn.cacheLayers {
if layer.baseRedisClient == nil {
continue
}
foundRedisLayer = true

keys, err := layer.scan(ctx, pattern, count)
if err != nil {
return nil, fmt.Errorf("%s: %w", layer.layerName, err)
}
for _, key := range keys {
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
matched = append(matched, key)
}
}

if !foundRedisLayer {
return nil, fmt.Errorf("%w: no redis-backed layers found", ErrLayerNotFound)
}

return matched, nil
}

// DeleteMany removes the provided keys from every layer of the cache instance.
func (mn *MnemosyneInstance) DeleteMany(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}

var errs []string
for _, layer := range mn.cacheLayers {
if err := layer.deleteMany(ctx, keys); err != nil && !errors.Is(err, redis.Nil) {
errs = append(errs, fmt.Sprintf("%s: %v", layer.layerName, err))
}
}

if len(errs) > 0 {
return fmt.Errorf("cache delete errors: %s", strings.Join(errs, "; "))
}
return nil
}

// DeleteByPattern scans for matching keys and deletes them from every layer.
func (mn *MnemosyneInstance) DeleteByPattern(ctx context.Context, pattern string, count int64) (int, error) {
keys, err := mn.Scan(ctx, pattern, count)
if err != nil {
return 0, err
}

if err := mn.DeleteMany(ctx, keys...); err != nil {
return 0, err
}

return len(keys), nil
}

// Flush completely clears a single layer of the cache
func (mn *MnemosyneInstance) Flush(targetLayerName string) error {
for _, layer := range mn.cacheLayers {
Expand Down
108 changes: 108 additions & 0 deletions tests/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,111 @@ func TestGetAndShouldUpdate(t *testing.T) {
assert.Equal(t, testCache, cachedData, "Cached data does not match original")
assert.False(t, shouldUpdate, "Should not update immediately after setting")
}

func TestScanReturnsMatchingKeys(t *testing.T) {
cacheInstance := setupTestCache(t)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

keys := map[string]TestType{
"result;alpha;1": {Name: "a1"},
"result;alpha;2": {Name: "a2"},
"result;beta;1": {Name: "b1"},
}

for key, value := range keys {
err := cacheInstance.Set(ctx, key, value)
assert.NoError(t, err, "Failed to set cache item %s", key)
}

matchedKeys, err := cacheInstance.Scan(ctx, "*;alpha;*", 100)
assert.NoError(t, err, "Failed to scan matching keys")
assert.ElementsMatch(t, []string{"result;alpha;1", "result;alpha;2"}, matchedKeys)
}

func TestDeleteManyDeletesSpecifiedKeys(t *testing.T) {
cacheInstance := setupTestCache(t)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

err := cacheInstance.Set(ctx, "result;one;key", TestType{Name: "one"})
assert.NoError(t, err)
err = cacheInstance.Set(ctx, "result;two;key", TestType{Name: "two"})
assert.NoError(t, err)
err = cacheInstance.Set(ctx, "result;three;key", TestType{Name: "three"})
assert.NoError(t, err)

err = cacheInstance.DeleteMany(ctx, "result;one;key", "result;two;key")
assert.NoError(t, err, "Failed to delete many keys")

var missing TestType
err = cacheInstance.Get(ctx, "result;one;key", &missing)
assert.ErrorIs(t, err, mnemosyne.ErrNotFound)
err = cacheInstance.Get(ctx, "result;two;key", &missing)
assert.ErrorIs(t, err, mnemosyne.ErrNotFound)

var remaining TestType
err = cacheInstance.Get(ctx, "result;three;key", &remaining)
assert.NoError(t, err, "Remaining key should still exist")
assert.Equal(t, TestType{Name: "three"}, remaining)
}

func TestDeleteByPatternDeletesMatchingKeys(t *testing.T) {
cacheInstance := setupTestCache(t)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

err := cacheInstance.Set(ctx, "result;delete;1", TestType{Name: "d1"})
assert.NoError(t, err)
err = cacheInstance.Set(ctx, "result;delete;2", TestType{Name: "d2"})
assert.NoError(t, err)
err = cacheInstance.Set(ctx, "result;keep;1", TestType{Name: "k1"})
assert.NoError(t, err)

deletedCount, err := cacheInstance.DeleteByPattern(ctx, "*;delete;*", 100)
assert.NoError(t, err, "Failed to delete by pattern")
assert.Equal(t, 2, deletedCount)

var deleted TestType
err = cacheInstance.Get(ctx, "result;delete;1", &deleted)
assert.ErrorIs(t, err, mnemosyne.ErrNotFound)
err = cacheInstance.Get(ctx, "result;delete;2", &deleted)
assert.ErrorIs(t, err, mnemosyne.ErrNotFound)

var kept TestType
err = cacheInstance.Get(ctx, "result;keep;1", &kept)
assert.NoError(t, err, "Non-matching key should remain")
assert.Equal(t, TestType{Name: "k1"}, kept)
}

func TestDeleteByPatternNoMatches(t *testing.T) {
cacheInstance := setupTestCache(t)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

err := cacheInstance.Set(ctx, "result;keep;key", TestType{Name: "keep"})
assert.NoError(t, err)

deletedCount, err := cacheInstance.DeleteByPattern(ctx, "*;missing;*", 100)
assert.NoError(t, err, "DeleteByPattern should not fail when nothing matches")
assert.Equal(t, 0, deletedCount)

var kept TestType
err = cacheInstance.Get(ctx, "result;keep;key", &kept)
assert.NoError(t, err, "Key should still exist")
assert.Equal(t, TestType{Name: "keep"}, kept)
}

func TestDeleteManyWithNoKeys(t *testing.T) {
cacheInstance := setupTestCache(t)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

err := cacheInstance.DeleteMany(ctx)
assert.NoError(t, err, "DeleteMany should allow empty input")
}