diff --git a/.agents/memory/short-term/caching-strategies.json b/.agents/memory/short-term/caching-strategies.json index c69eae3..f21d201 100644 --- a/.agents/memory/short-term/caching-strategies.json +++ b/.agents/memory/short-term/caching-strategies.json @@ -3,7 +3,6 @@ "current_phase": "backend", "status": "in_progress", "pending_tasks": [ - "Phase 6: Bulk Operations", "Phase 7: Invalidation Strategies", "Phase 8: CacheObserver", "Phase 9: WarmingManager", @@ -100,7 +99,12 @@ "phase": "backend-phase5", "notes": ["Phase 5 complete: cache.go with Cache interface implementation (Get, Set, Delete, GetOrFetch), cache_test.go with 20+ test cases. Fixed errors.go sentinel code map. QA PASSED."], "timestamp": "2026-03-26T13:16:00Z" + }, + { + "phase": "backend-phase6", + "notes": ["Phase 6 complete: GetMany, SetMany, DeleteMany, DeletePattern, DeleteByTag bulk operations. 6 test cases added. QA PASSED."], + "timestamp": "2026-03-26T14:00:00Z" } ], - "last_updated": "2026-03-26T13:16:00Z" + "last_updated": "2026-03-26T14:00:00Z" } \ No newline at end of file diff --git a/backend/scripts/docs-gen/docs-gen b/backend/scripts/docs-gen/docs-gen index 36045fa..02b77de 100755 Binary files a/backend/scripts/docs-gen/docs-gen and b/backend/scripts/docs-gen/docs-gen differ diff --git a/backend/shared/caching/cache.go b/backend/shared/caching/cache.go index 2a51b53..7816f94 100644 --- a/backend/shared/caching/cache.go +++ b/backend/shared/caching/cache.go @@ -347,28 +347,185 @@ func (c *cacheImpl) WithDefaultTags(tags ...string) Cache { return newCache } -// GetMany implements the GetMany method (not required for this phase). +// GetMany retrieves multiple values from the cache. +// Returns a map containing only the keys that were found (hits). +// Returns an error if key resolution fails. func (c *cacheImpl) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) { - return nil, nil + // Resolve all keys + resolvedKeys := make([]string, 0, len(keys)) + for _, key := range keys { + resolvedKey, err := c.resolveKey(key) + if err != nil { + return nil, err + } + resolvedKeys = append(resolvedKeys, resolvedKey) + } + + start := time.Now() + + // Call backend GetMany + result, err := c.backend.GetMany(ctx, resolvedKeys) + if err != nil { + return nil, err + } + + // Calculate latency per key and call observer + latencyMs := float64(time.Since(start).Nanoseconds()) / 1e6 + perKeyLatency := latencyMs / float64(len(resolvedKeys)) + + // Observe each key - hits are in result map, misses are not + for _, key := range resolvedKeys { + hit := false + if _, ok := result[key]; ok { + hit = true + } + c.observer.ObserveGet(ctx, c.namespace, key, hit, perKeyLatency) + } + + // Return map of hits only (nil if empty) + if result == nil { + result = make(map[string][]byte) + } + return result, nil } -// SetMany implements the SetMany method (not required for this phase). +// SetMany stores multiple values in the cache. +// Returns ErrMaxSizeExceeded if any value exceeds maxSize. +// Returns error if any value is nil. func (c *cacheImpl) SetMany(ctx context.Context, entries map[string][]byte, opts ...SetOption) error { + // Validate all values not nil and check maxSize + for _, value := range entries { + if value == nil { + return ErrSerializationFailed + } + if int64(len(value)) > c.maxSize { + return MaxSizeExceededError(int64(len(value)), c.maxSize) + } + } + + // Apply SetOptions + setOpts := applySetOptions(opts...) + + // Apply defaults + ttl := setOpts.TTL + if ttl == 0 { + ttl = c.defaultTTL + } + tags := setOpts.Tags + if len(tags) == 0 { + tags = c.defaultTags + } + + // Resolve all keys and build entries map + resolvedEntries := make(map[string][]byte) + resolvedKeys := make([]string, 0, len(entries)) + for key, value := range entries { + resolvedKey, err := c.resolveKey(key) + if err != nil { + return err + } + resolvedEntries[resolvedKey] = value + resolvedKeys = append(resolvedKeys, resolvedKey) + } + + start := time.Now() + + // Call backend SetMany with TTL + err := c.backend.SetMany(ctx, resolvedEntries, ttl) + if err != nil { + return err + } + + // Update tag index for all entries if tags provided + if len(tags) > 0 { + for _, resolvedKey := range resolvedKeys { + for _, tag := range tags { + tagKey := "_tags:" + tag + ":" + resolvedKey + _ = c.backend.Set(ctx, tagKey, []byte("1"), ttl) + } + } + } + + // Calculate latency per key and call observer + latencyMs := float64(time.Since(start).Nanoseconds()) / 1e6 + perKeyLatency := latencyMs / float64(len(resolvedKeys)) + + for _, key := range resolvedKeys { + size := int64(len(resolvedEntries[key])) + c.observer.ObserveSet(ctx, c.namespace, key, size, perKeyLatency) + } + return nil } -// DeleteMany implements the DeleteMany method (not required for this phase). +// DeleteMany removes multiple values from the cache. func (c *cacheImpl) DeleteMany(ctx context.Context, keys []string) error { + // Resolve all keys + resolvedKeys := make([]string, 0, len(keys)) + for _, key := range keys { + resolvedKey, err := c.resolveKey(key) + if err != nil { + return err + } + resolvedKeys = append(resolvedKeys, resolvedKey) + } + + // Call backend DeleteMany + err := c.backend.DeleteMany(ctx, resolvedKeys) + if err != nil { + return err + } + + // Call observer for each key with reason "manual" + for _, key := range resolvedKeys { + c.observer.ObserveDelete(ctx, c.namespace, key, "manual") + } + return nil } -// DeletePattern implements the DeletePattern method (not required for this phase). +// DeletePattern removes all keys matching the given pattern. +// Returns ErrPatternInvalid if pattern is bare "*" or doesn't include agentID prefix. func (c *cacheImpl) DeletePattern(ctx context.Context, pattern string) error { + // Validate pattern is not bare "*" + if pattern == "*" { + return PatternInvalidError(pattern, "bare wildcard not allowed") + } + + // Validate pattern includes agentID prefix + if c.agentID != "" && !strings.Contains(pattern, c.agentID) { + return PatternInvalidError(pattern, "pattern must include agentID prefix") + } + + // Call backend DeletePattern + err := c.backend.DeletePattern(ctx, pattern) + if err != nil { + return err + } + + // Call observer with reason "pattern" + c.observer.ObserveDelete(ctx, c.namespace, pattern, "pattern") + return nil } -// DeleteByTag implements the DeleteByTag method (not required for this phase). +// DeleteByTag removes all entries with the given tag. +// Returns error if tag is empty. func (c *cacheImpl) DeleteByTag(ctx context.Context, tag string) error { + // Validate tag not empty + if tag == "" { + return ErrTagNotFound + } + + // Call backend DeleteByTag + err := c.backend.DeleteByTag(ctx, tag) + if err != nil { + return err + } + + // Call observer with reason "tag" + c.observer.ObserveDelete(ctx, c.namespace, "_tags:"+tag, "tag") + return nil } diff --git a/backend/shared/caching/cache_test.go b/backend/shared/caching/cache_test.go index ff3f072..c5d3400 100644 --- a/backend/shared/caching/cache_test.go +++ b/backend/shared/caching/cache_test.go @@ -555,3 +555,518 @@ func TestCache_Set_DefaultTTL(t *testing.T) { require.NoError(t, err) assert.InDelta(t, 45*time.Minute, ttl, float64(time.Second), "TTL should be approximately 45m") } + +// ============================================================================= +// Bulk Operations Tests (Phase 6) +// ============================================================================= + +// TestGetMany_PartialHits tests that GetMany returns a map with only the hits. +func TestGetMany_PartialHits(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + // Set 2 of 3 keys + err := cache.Set(context.Background(), "user:1", []byte("value1")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:2", []byte("value2")) + require.NoError(t, err) + // Don't set user:3 + + // Act + keys := []string{"user:1", "user:2", "user:3"} + result, err := cache.GetMany(context.Background(), keys) + + // Assert + require.NoError(t, err) + assert.Len(t, result, 2, "should have 2 hits") + assert.Contains(t, result, "test-ns:agent-1:user:1:") + assert.Contains(t, result, "test-ns:agent-1:user:2:") + assert.NotContains(t, result, "test-ns:agent-1:user:3:") +} + +// TestSetMany_Success tests that SetMany stores all entries. +func TestSetMany_Success(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + entries := map[string][]byte{ + "user:1": []byte("value1"), + "user:2": []byte("value2"), + "user:3": []byte("value3"), + } + + // Act + err := cache.SetMany(context.Background(), entries) + + // Assert + require.NoError(t, err) + + // Verify all entries are stored + value, err := cache.Get(context.Background(), "user:1") + require.NoError(t, err) + assert.Equal(t, []byte("value1"), value) + + value, err = cache.Get(context.Background(), "user:2") + require.NoError(t, err) + assert.Equal(t, []byte("value2"), value) + + value, err = cache.Get(context.Background(), "user:3") + require.NoError(t, err) + assert.Equal(t, []byte("value3"), value) +} + +// TestDeleteMany_Success tests that DeleteMany removes all specified keys. +func TestDeleteMany_Success(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + // Set some values + err := cache.Set(context.Background(), "user:1", []byte("value1")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:2", []byte("value2")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:3", []byte("value3")) + require.NoError(t, err) + + // Act - Delete user:1 and user:2 + err = cache.DeleteMany(context.Background(), []string{"user:1", "user:2"}) + + // Assert + require.NoError(t, err) + + // Verify user:1 and user:2 are gone + value, err := cache.Get(context.Background(), "user:1") + require.NoError(t, err) + assert.Nil(t, value) + + value, err = cache.Get(context.Background(), "user:2") + require.NoError(t, err) + assert.Nil(t, value) + + // Verify user:3 still exists + value, err = cache.Get(context.Background(), "user:3") + require.NoError(t, err) + assert.Equal(t, []byte("value3"), value) +} + +// TestDeletePattern_BareStarRejected tests that DeletePattern rejects bare wildcard. +func TestDeletePattern_BareStarRejected(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + // Act + err := cache.DeletePattern(context.Background(), "*") + + // Assert + require.Error(t, err) + assert.ErrorIs(t, err, ErrPatternInvalid) +} + +// TestDeletePattern_ValidPattern tests that DeletePattern works with valid pattern. +func TestDeletePattern_ValidPattern(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + // Set some values + err := cache.Set(context.Background(), "user:1", []byte("value1")) + require.NoError(t, err) + + // Act - Delete pattern that includes agentID prefix + err = cache.DeletePattern(context.Background(), "test-ns:agent-1:*") + + // Assert + require.NoError(t, err) +} + +// TestDeleteByTag_TagExists tests that DeleteByTag removes entries with that tag. +func TestDeleteByTag_TagExists(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + // Set values with tags + err := cache.Set(context.Background(), "user:1", []byte("value1"), WithTags("tag1")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:2", []byte("value2"), WithTags("tag1")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:3", []byte("value3"), WithTags("tag2")) + require.NoError(t, err) + + // Act - Delete by tag + err = cache.DeleteByTag(context.Background(), "tag1") + + // Assert + require.NoError(t, err) +} + +// ============================================================================= +// Mock CacheObserver +// ============================================================================= + +// recordingObserver records all observer calls for test assertions. +type recordingObserver struct { + mu sync.Mutex + getCalls []observerGetCall + setCalls []observerSetCall + deleteCalls []observerDeleteCall +} + +type observerGetCall struct { + Namespace string + Key string + Hit bool + LatencyMs float64 +} + +type observerSetCall struct { + Namespace string + Key string + SizeBytes int64 + LatencyMs float64 +} + +type observerDeleteCall struct { + Namespace string + Key string + Reason string +} + +func (r *recordingObserver) ObserveGet(ctx context.Context, namespace, key string, hit bool, latencyMs float64) { + r.mu.Lock() + defer r.mu.Unlock() + r.getCalls = append(r.getCalls, observerGetCall{Namespace: namespace, Key: key, Hit: hit, LatencyMs: latencyMs}) +} + +func (r *recordingObserver) ObserveSet(ctx context.Context, namespace, key string, sizeBytes int64, latencyMs float64) { + r.mu.Lock() + defer r.mu.Unlock() + r.setCalls = append(r.setCalls, observerSetCall{Namespace: namespace, Key: key, SizeBytes: sizeBytes, LatencyMs: latencyMs}) +} + +func (r *recordingObserver) ObserveDelete(ctx context.Context, namespace, key, reason string) { + r.mu.Lock() + defer r.mu.Unlock() + r.deleteCalls = append(r.deleteCalls, observerDeleteCall{Namespace: namespace, Key: key, Reason: reason}) +} + +func (r *recordingObserver) ObserveEviction(ctx context.Context, namespace, key, reason string) {} + +func (r *recordingObserver) ObserveWarming(ctx context.Context, namespace string, progress WarmingProgress) { +} + +func (r *recordingObserver) getCallsCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.getCalls) +} + +func (r *recordingObserver) setCallsCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.setCalls) +} + +func (r *recordingObserver) deleteCallsCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.deleteCalls) +} + +func (r *recordingObserver) getGetCalls() []observerGetCall { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]observerGetCall, len(r.getCalls)) + copy(out, r.getCalls) + return out +} + +func (r *recordingObserver) getSetCalls() []observerSetCall { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]observerSetCall, len(r.setCalls)) + copy(out, r.setCalls) + return out +} + +func (r *recordingObserver) getDeleteCalls() []observerDeleteCall { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]observerDeleteCall, len(r.deleteCalls)) + copy(out, r.deleteCalls) + return out +} + +// ============================================================================= +// Additional Bulk Operation Tests +// ============================================================================= + +// TestGetMany_AllHits tests that GetMany returns a map with all entries when all keys are present. +func TestGetMany_AllHits(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + err := cache.Set(context.Background(), "user:1", []byte("value1")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:2", []byte("value2")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:3", []byte("value3")) + require.NoError(t, err) + + // Act + keys := []string{"user:1", "user:2", "user:3"} + result, err := cache.GetMany(context.Background(), keys) + + // Assert + require.NoError(t, err) + assert.Len(t, result, 3, "should have 3 hits") + assert.Contains(t, result, "test-ns:agent-1:user:1:") + assert.Contains(t, result, "test-ns:agent-1:user:2:") + assert.Contains(t, result, "test-ns:agent-1:user:3:") + assert.Equal(t, []byte("value1"), result["test-ns:agent-1:user:1:"]) + assert.Equal(t, []byte("value2"), result["test-ns:agent-1:user:2:"]) + assert.Equal(t, []byte("value3"), result["test-ns:agent-1:user:3:"]) +} + +// TestGetMany_EmptyKeys tests that GetMany returns an empty map when given an empty keys slice. +func TestGetMany_EmptyKeys(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + // Act + result, err := cache.GetMany(context.Background(), []string{}) + + // Assert + require.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result, 0, "should return empty map for empty keys") +} + +// TestSetMany_NilValue tests that SetMany returns ErrSerializationFailed when one value is nil. +func TestSetMany_NilValue(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + entries := map[string][]byte{ + "user:1": []byte("value1"), + "user:2": nil, + } + + // Act + err := cache.SetMany(context.Background(), entries) + + // Assert + require.Error(t, err) + assert.ErrorIs(t, err, ErrSerializationFailed) +} + +// TestSetMany_ExceedsMaxSize tests that SetMany returns ErrMaxSizeExceeded when a value exceeds maxSize. +func TestSetMany_ExceedsMaxSize(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1"), WithMaxSize(10)) + + entries := map[string][]byte{ + "user:1": []byte("ok"), + "user:2": []byte("this value is way too long"), + } + + // Act + err := cache.SetMany(context.Background(), entries) + + // Assert + require.Error(t, err) + assert.ErrorIs(t, err, ErrMaxSizeExceeded) +} + +// TestSetMany_WithTags tests that SetMany stores entries when WithTags option is used. +func TestSetMany_WithTags(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + entries := map[string][]byte{ + "user:1": []byte("value1"), + "user:2": []byte("value2"), + } + + // Act + err := cache.SetMany(context.Background(), entries, WithTags("tag-a", "tag-b")) + require.NoError(t, err) + + // Assert - entries should still be stored + value, err := cache.Get(context.Background(), "user:1") + require.NoError(t, err) + assert.Equal(t, []byte("value1"), value) + + value, err = cache.Get(context.Background(), "user:2") + require.NoError(t, err) + assert.Equal(t, []byte("value2"), value) +} + +// TestDeleteByTag_EmptyTag tests that DeleteByTag returns ErrTagNotFound for empty tag. +func TestDeleteByTag_EmptyTag(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1")) + + // Act + err := cache.DeleteByTag(context.Background(), "") + + // Assert + require.Error(t, err) + assert.ErrorIs(t, err, ErrTagNotFound) +} + +// TestGetMany_ObserverCalledForEachHit tests that the observer is called for each key in GetMany. +func TestGetMany_ObserverCalledForEachHit(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + obs := &recordingObserver{} + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1"), WithObserver(obs)) + + err := cache.Set(context.Background(), "user:1", []byte("value1")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:2", []byte("value2")) + require.NoError(t, err) + // user:3 is not set (miss) + + // Act + _, err = cache.GetMany(context.Background(), []string{"user:1", "user:2", "user:3"}) + require.NoError(t, err) + + // Assert - observer should be called 3 times (once per key) + getCalls := obs.getGetCalls() + assert.Len(t, getCalls, 3, "observer should be called once per key") + + // Check that hits and misses are correctly reported + hitCount := 0 + missCount := 0 + for _, call := range getCalls { + if call.Hit { + hitCount++ + } else { + missCount++ + } + } + assert.Equal(t, 2, hitCount, "should have 2 hits") + assert.Equal(t, 1, missCount, "should have 1 miss") +} + +// TestSetMany_ObserverCalledForEach tests that the observer is called for each entry in SetMany. +func TestSetMany_ObserverCalledForEach(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + obs := &recordingObserver{} + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1"), WithObserver(obs)) + + entries := map[string][]byte{ + "user:1": []byte("value1"), + "user:2": []byte("value2"), + "user:3": []byte("value3"), + } + + // Act + err := cache.SetMany(context.Background(), entries) + require.NoError(t, err) + + // Assert - observer should be called 3 times (once per entry) + setCalls := obs.getSetCalls() + assert.Len(t, setCalls, 3, "observer should be called once per entry") + + // All calls should be for namespace "test-ns" + for _, call := range setCalls { + assert.Equal(t, "test-ns", call.Namespace) + assert.True(t, call.SizeBytes > 0, "size should be positive") + } +} + +// TestDeleteMany_ObserverCalledForEach tests that the observer is called for each key in DeleteMany. +func TestDeleteMany_ObserverCalledForEach(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + obs := &recordingObserver{} + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1"), WithObserver(obs)) + + // Set values first + err := cache.Set(context.Background(), "user:1", []byte("value1")) + require.NoError(t, err) + err = cache.Set(context.Background(), "user:2", []byte("value2")) + require.NoError(t, err) + + // Clear set calls from the setup + obs.setCalls = nil + + // Act + err = cache.DeleteMany(context.Background(), []string{"user:1", "user:2"}) + require.NoError(t, err) + + // Assert - observer should be called 2 times (once per deleted key) + deleteCalls := obs.getDeleteCalls() + assert.Len(t, deleteCalls, 2, "observer should be called once per deleted key") + + // All calls should have reason "manual" + for _, call := range deleteCalls { + assert.Equal(t, "test-ns", call.Namespace) + assert.Equal(t, "manual", call.Reason) + } +} + +// TestDeletePattern_ObserverCalled tests that the observer is called when DeletePattern is invoked. +func TestDeletePattern_ObserverCalled(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + obs := &recordingObserver{} + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1"), WithObserver(obs)) + + // Set a value first + err := cache.Set(context.Background(), "user:1", []byte("value1")) + require.NoError(t, err) + + // Clear set calls from the setup + obs.setCalls = nil + + // Act + err = cache.DeletePattern(context.Background(), "test-ns:agent-1:*") + require.NoError(t, err) + + // Assert - observer should be called once with reason "pattern" + deleteCalls := obs.getDeleteCalls() + require.Len(t, deleteCalls, 1, "observer should be called once for DeletePattern") + assert.Equal(t, "test-ns", deleteCalls[0].Namespace) + assert.Equal(t, "test-ns:agent-1:*", deleteCalls[0].Key) + assert.Equal(t, "pattern", deleteCalls[0].Reason) +} + +// TestDeleteByTag_ObserverCalled tests that the observer is called when DeleteByTag is invoked. +func TestDeleteByTag_ObserverCalled(t *testing.T) { + // Arrange + backend := NewMockCacheBackend() + obs := &recordingObserver{} + cache := NewCache(backend, WithNamespace("test-ns"), WithAgentID("agent-1"), WithObserver(obs)) + + // Set a value with a tag first + err := cache.Set(context.Background(), "user:1", []byte("value1"), WithTags("my-tag")) + require.NoError(t, err) + + // Clear set calls from the setup + obs.setCalls = nil + + // Act + err = cache.DeleteByTag(context.Background(), "my-tag") + require.NoError(t, err) + + // Assert - observer should be called once with reason "tag" + deleteCalls := obs.getDeleteCalls() + require.Len(t, deleteCalls, 1, "observer should be called once for DeleteByTag") + assert.Equal(t, "test-ns", deleteCalls[0].Namespace) + assert.Equal(t, "_tags:my-tag", deleteCalls[0].Key) + assert.Equal(t, "tag", deleteCalls[0].Reason) +} diff --git a/documentation/changelogs/2026-03-26.md b/documentation/changelogs/2026-03-26.md index 1eefb57..c52dbcf 100644 --- a/documentation/changelogs/2026-03-26.md +++ b/documentation/changelogs/2026-03-26.md @@ -6,6 +6,8 @@ - Created cache.go - Cache interface implementation with Get, Set, Delete, GetOrFetch operations - Created cache_test.go - 20+ test cases covering all core cache operations including stampede protection - Added sentinel code map to errors.go for proper error matching with errors.Is() +- Implemented bulk operations: GetMany, SetMany, DeleteMany, DeletePattern, DeleteByTag +- Added 6 test cases for bulk operations ### Fixed - Added sentinel code map to CacheError.Is() for proper error matching