diff --git a/changelogs/unreleased/9908-adam-jian-zhang b/changelogs/unreleased/9908-adam-jian-zhang new file mode 100644 index 000000000..2ba573609 --- /dev/null +++ b/changelogs/unreleased/9908-adam-jian-zhang @@ -0,0 +1 @@ +Fix issue #9907, add cache for the GetNamespaceFilter call diff --git a/pkg/backup/backup_test.go b/pkg/backup/backup_test.go index 1c5ef7f39..56f4aaf33 100644 --- a/pkg/backup/backup_test.go +++ b/pkg/backup/backup_test.go @@ -6107,15 +6107,43 @@ func TestGetNamespaceFilter(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // First call (populates cache) result := req.GetNamespaceFilter(tt.namespace) if tt.expectNil { assert.Nil(t, result) + + // Verify negative cache + val, ok := req.NamespaceFilterCache.Load(tt.namespace) + assert.True(t, ok) + assert.Nil(t, val) } else { assert.NotNil(t, result) // Ensure the returned filter points to the correct reference in our map assert.Same(t, filterMap[tt.expectMatched], result) + + // Verify positive cache + val, ok := req.NamespaceFilterCache.Load(tt.namespace) + assert.True(t, ok) + assert.Same(t, filterMap[tt.expectMatched], val) } + + // Second call (hits cache) + result2 := req.GetNamespaceFilter(tt.namespace) + assert.Same(t, result, result2) }) } } + +func TestGetNamespaceFilter_CacheBypass(t *testing.T) { + req := &Request{ + NamespacedFilterMap: make(map[string]*ResolvedNamespaceFilter), + } + + cachedFilter := &ResolvedNamespaceFilter{} + req.NamespaceFilterCache.Store("cached-ns", cachedFilter) + + // Since NamespacedFilterMap is empty, this would normally return nil, + // but the cache should return our cachedFilter. + assert.Same(t, cachedFilter, req.GetNamespaceFilter("cached-ns")) +} diff --git a/pkg/backup/request.go b/pkg/backup/request.go index ca2e638c2..7ace38125 100644 --- a/pkg/backup/request.go +++ b/pkg/backup/request.go @@ -100,6 +100,10 @@ type Request struct { // NamespacedFilterPatterns preserves the order of patterns for first-match semantics // and caches pre-compiled globs to avoid repeated compilation in the hot path. NamespacedFilterPatterns []NamespacedFilterPattern + + // NamespaceFilterCache memoizes the resolved filter for a given namespace. + // sync.Map is used because item backuppers access this concurrently. + NamespaceFilterCache sync.Map } // NamespacedFilterPattern pairs a namespace pattern string with its pre-compiled @@ -149,22 +153,37 @@ func (r *Request) StopWorkerPool() { // GetNamespaceFilter returns the resolved filter for a namespace, or nil // if the namespace should use global filters. Uses first-match semantics -// when multiple patterns could match the same namespace. +// when multiple patterns could match the same namespace, but exact matches +// always take precedence over glob patterns regardless of definition order. func (r *Request) GetNamespaceFilter(namespace string) *ResolvedNamespaceFilter { if r.NamespacedFilterMap == nil { return nil } - // First check for exact match + // 1. Check the concurrent cache first + if val, ok := r.NamespaceFilterCache.Load(namespace); ok { + if val == nil { + return nil + } + return val.(*ResolvedNamespaceFilter) + } + + // 2. Check for exact match first if f, ok := r.NamespacedFilterMap[namespace]; ok { + r.NamespaceFilterCache.Store(namespace, f) return f } - // Walk patterns in definition order using pre-compiled globs (no allocation per call) + // 3. Walk patterns in definition order using pre-compiled globs for _, p := range r.NamespacedFilterPatterns { if p.Compiled != nil && p.Compiled.Match(namespace) { - return r.NamespacedFilterMap[p.Pattern] + filter := r.NamespacedFilterMap[p.Pattern] + r.NamespaceFilterCache.Store(namespace, filter) + return filter } } + + // 4. Cache the miss + r.NamespaceFilterCache.Store(namespace, nil) return nil }