From d21fef4eccc3af137bfdc96ee020c86754d59fa6 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 26 Mar 2026 14:00:06 +0200 Subject: [PATCH 1/3] feat: Phase 6 - Bulk Operations implementation [unit: caching-strategies] - Implement GetMany, SetMany, DeleteMany, DeletePattern, DeleteByTag - Add 6 test cases for bulk operations - All tests pass - QA passed with PASS status --- backend/scripts/docs-gen/docs-gen | Bin 14884108 -> 14884108 bytes backend/shared/caching/cache.go | 169 ++++++++++++++++++++++++++- backend/shared/caching/cache_test.go | 148 +++++++++++++++++++++++ 3 files changed, 311 insertions(+), 6 deletions(-) diff --git a/backend/scripts/docs-gen/docs-gen b/backend/scripts/docs-gen/docs-gen index 36045fade48c849f28e77a5617a1fb8cf97109b4..35714aa3885a4feacd7d1e2efb28ede7568181e3 100755 GIT binary patch delta 1169 zcmbWzS631M0ES^o&1^_R+u1G~1_%h)RC1+=Td*Y%7-poRIT~t~n9-ul_8wMNHYwX# z+1`8GU56h~=XBHCU+Chw`Of)nUT2Hb*NNslVTGj3CXLeT^tLFaB_md*%Cu_=MB2#Q zWUVGTDn=>q?so~A#)Gg_NNbnP|2YO2I-?m$tV)fgrdReOXEkFWze!VBK; zK_~d4GyKp6UC|BQ;g5gc9_Wc)=#2mbq7V9_ANpee24WBfBM3t<6vHqaBQO%9FdAbJ zjIkJp@tA-ROoRwxNDvAsWRN2a3MipMI3{5-reG?jVLBoZ2{mRQ3eku`EaET|@koFM ziO?bm$w)ye(vXe}WJ2fm2=#@f!4^fRStd1$O>((KVUSv6CbL{6Rw>L9mC0mM$|Vvu zl~K_kcGU;@NX6ly5=UdL9$A=$*_ea5n1^f_V1xu@HG!1REA39|b6c z9YrX{5|p46Whlo|EW>iFKm{sMg_WqrDy&8g9H_+_)L|{0Scmo4fQ{G$7dB&yyRo)D z+wXr$HBc(VQR%A3Ry1H6wqpl&Vi$H}5B6do_TvB!;t&qw2#%r=$8a1c(1epXh0|!p z8JxvAoW})Rgd3M|87;VitGI^ixPhCvh1+Pw9o)q|wBbG;;2|F2F`nQlp5ZxO;3Zz+ eHQwMY-r+qy;3GcaGrr&}zTrFC-IcB%&bq(O^do%$ delta 1169 zcmbWz*+a_#0KoCl?V2M;j*u(I>}dP7a&NL^ownI>)mo#PO4lk!5lZG7$$eAqoFn9x zM96&~4_@*|`1%(-d>+3qUst2c&xHnuxJ*(UYtorD5k;joeY7*vo}tf4l*Xjn4e=!! ziAGy!vXy9!N=vjLwPu7HVh!1OdTo5J*(#Je3ylR*OR}I(a@wLKdbvZJWDc|IVof4% zkw_E>FX-ZoqWq@pEdM_tL2WGw?Y_Rvt#!JP-%>}0RZvPRI>eP7;!fxcFL=WTzVL%T z0?-9r(GA_v1OM8d=!M?sgFy5}KlH}{1YsZsVK9au7(+1(!x4fJ7>Q9BjWGztSd7DX zOn?LvA%zTbC=doER1gpjH6jp+Ntlc&n2Kqbjv3HEiMyQ@(8<9P>0FWtf>lRc&Z>*TUB-;EKRVd%WMjv zR_3k=_K~Y1Rf@{`DhrY^9}BP$i?A3eNQD(?upu3GEWuJ_AQM?|U>UNJgIp{}9`aFu zLKMM?Vw9j1E3gu)uo`73#~Q3f1=e9bHlPw!*obOuf(x6m1zWKV+u_Cz?DW)E)uaUc zNYNh-_THQ0q(?8YAK#XjuE0UX339L5nG#W5Vm37kY7>TwFEaRz5`4(D+J4Y-I) zxQr{fifi!TI&Pp5H*pKM(S$p=i+i|_2Y84_c#LK|K?|Ou70>V-ZFqr~c!hSn#v8oF cJG{pSe8eYw#ut3WH+;tr{PdK&f4Qpv0-Y5g6951J 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..16a75b9 100644 --- a/backend/shared/caching/cache_test.go +++ b/backend/shared/caching/cache_test.go @@ -555,3 +555,151 @@ 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) +} From 36c64d84f8f83af22744af565da89a96aef440c3 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 26 Mar 2026 14:03:01 +0200 Subject: [PATCH 2/3] chore: update changelog and memory for Phase 6 [unit: caching-strategies] --- .../memory/short-term/caching-strategies.json | 8 ++++++-- backend/scripts/docs-gen/docs-gen | Bin 14884108 -> 14884108 bytes documentation/changelogs/2026-03-26.md | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) 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 35714aa3885a4feacd7d1e2efb28ede7568181e3..7ac98662010aa5727b71d7c3816a42d1c9335b86 100755 GIT binary patch delta 1173 zcmbWzRaesi06_66C}PdP20Jit!S*Lq6gNs>grh82!f+tqNCO0Qh%=ZX28t~fVoory zySwZ7#yNfjj@Pf?!|(CFTt}P3!+{p7xZDt^DpqMVYMa4Yl%mPkg{cz+i7GNHH7Z^m z8mb9TF3D0`6n-|DL~jYu#%gm5VpMT?sd}NTxG>2kQK#9Au~MbJEG)4!GuoP`$W-Tu z+(jah7u*n`NfJe8|JLOCG_-cM{|Rw+)ZF+=!M?sgTDCJ_CtRRz(9Cm5C&rihGH0oV+6e6gOM18(HMiV7>DtgfQgs{Urfdn zOvN<#VLBv`LIyefp@0$s0uYEG1Y-tfVirO$8*>nfxllokFoYulk%+=PL?Z^V&>#-+ zNI)XyLyIINBL%6h4slvRiLX&EGn>o`lfhu{*O_Jh0eZ915G0sPQn}O+C1HGV5u}%D%hK<)35*wu?UN?1nJ0t4tf}1gb8M3A`2E|V=1i2K`xdd5BVs- za@bIaA{3(pr6|J+ti&p;MmZ|529>D7TCBr**inrQsKG`!unC*71zS-IC$?d`tEsv! z!}I@M)$_{FUg5064%A~Oc40U6paFZa4~^K512~97IE*7WieqTPah$+OoWf}|;|yAG z7Uyst7jO}m;KF6Jq77GY71wYb?YM!PxP{xegS)tg`*?tdc!bAzf~R1q7)JH4eT*w9w}tGZI!>JE_P+RUK}NtH+J@QB-?J-pzJ z4(JFUbb>EBqYJvC8~pI^+Z{d76TRS%0Q5#5^hH1P#{dLkAO>MDhF~a$VK_!$Bt~I0 zf-nYSF%IJqj0uoH3K>EmhXP83LIpK62*X57!emUrR7^uSBA~@|L?Q~&h(Ro7AP(`+ zApv?MA_>VzK`PRajtm&w9}?u_)vgsi0I^RVKMrAy||esYYdyX#_z~D`he^ zjagMMb=3v>$in2(un?J}p~i?z%)~6r#vIH=7ECZhz&u!BMK4>)V;i<(2XV2o4AGBXu%!a#XYp*J|5s99^o;b;3=NrIbPr; gUg0&~;4R+aJwD(gKH)RI;48l2JKEe8t{=|Yzu8(OQvd(} 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 From ff4cea7f3139e54f3e4ea526e94ce3353a0618b0 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 26 Mar 2026 14:36:05 +0200 Subject: [PATCH 3/3] test: add missing TestDeleteByTag_ObserverCalled [unit: caching-strategies] --- backend/scripts/docs-gen/docs-gen | Bin 14884108 -> 14884108 bytes backend/shared/caching/cache_test.go | 367 +++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) diff --git a/backend/scripts/docs-gen/docs-gen b/backend/scripts/docs-gen/docs-gen index 7ac98662010aa5727b71d7c3816a42d1c9335b86..02b77def75e82ac4f4fe4ca4065531e1d06598f7 100755 GIT binary patch delta 1167 zcmbWz>0iqM0D$pPB-b1{liYI5*7j?y+_H68_eQyDXHDl;bU2ElWTKJWLdYFqQ9BjWHOD0F1+U zOu$4)FbPt~5C}OG5D)|aP(y=oL?9ATm;)`M5rbI7 zAsz`xgbsSl#XKar+r`O_!T^QRq!i35!K$*V6b7qY9xMn3YmhR?Y*2+5LX-xJRS;w* zMV-u5>+h)ul?7HbRwiRU7GNP3VKGvW3ImKV!3+zmNJBboSb_{>A`98bK`!iYAP@Oi zie)H3A&O9ph@&`$<2ZqnIEB+_!Wo=J zGtS{WF2Ie8xP%s5#uZ$}HC)FH+{7*1#vR;6E81`m_wfJ^@d%Ic1W)k{&+!5;@d~f; b25<2W@9_a2@d=;t1z+(E-`%CIAI_@3T*e|f delta 1167 zcmbWzRaesi06_66C}PdP7O};^ZLk1T6gNs>grh9j{@Y+U5OAacf;z+*Oc4Xc77H;a z7}(w2b$sI-KLW?=SMcHYcwerg&Eerdi&b1M1geYGI<3YgSc_7$`T8(Tf>NT6%u0=l z*Mx>@!;?$0G#0s^O(ro|RJvGQZb6JXE-%%fEGsTdvPm>)He;+*VJHhrEX|Cz=E*ZP zIU;wFNaO`KL}-&l(b>PXx!w(}o$Y@@oSn6i&5f1m6060aluN2Q#P$wx7j%Ui+|do) z;ej6TL{IcWZ}dT5{A>H6KL%hRyf6rZF$6;~48t)3BjJrv@WE(|!B~vLcuc@VOoA^a zV+y8X8vHOF5=bF~KLQ|!0!pY5h#&-G24-RwLNFV15Q@1_LxV7cBLb0#!aPJH2C>j0 z4)I7pBIZMfBqSpRsjd!jT0x1g(O+gZndK%y5CZgOS%Au5HVQ#XlS%3?6#|tih1{qJ zGV7J~QfHlyyEIs;v^Q0!VF4Cm5f)3l?N!DXhprE|wt=`6$40 z*ieWf6r%*CD8mY@#44;tIV!LQm8im6tiyWPQH>3#!A3Z+37fG6TTu%qwqd)gsk$!1 z^FKz_Gs@3i;jF_B)MF=hVK?@m0ei6zjo6O^IEX_yj3YRTV`##0oWMz(!f7<)3|ep& z=Wreua1oc_!ez9g4Oeg#*Ki%}xPhCvh1 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) +}