From e4cc95cf848d86881e1d66ae774a31a24f998d43 Mon Sep 17 00:00:00 2001 From: MohaGolkar Date: Sun, 10 May 2026 20:28:34 +0330 Subject: [PATCH] feat: add Scan, DeleteMany and DeleteByPattern --- cache.go | 60 ++++++++++++++++++++++++ core.go | 71 +++++++++++++++++++++++++++-- tests/cache_test.go | 108 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 3 deletions(-) diff --git a/cache.go b/cache.go index 534c6b1..7250bce 100644 --- a/cache.go +++ b/cache.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "math/rand" + "strings" "sync" "time" @@ -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") diff --git a/core.go b/core.go index 4975d00..1eb9764 100644 --- a/core.go +++ b/core.go @@ -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 { @@ -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)) @@ -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{ @@ -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 { diff --git a/tests/cache_test.go b/tests/cache_test.go index f534392..154d4ee 100644 --- a/tests/cache_test.go +++ b/tests/cache_test.go @@ -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") +}