Unified Storage/Search: Add max count config for indexing (#107255)

* Add max count config for indexing
* Build empty index when max count is exceeded
* Address linting
* Refactor buildIndexes
* Add test for max count threshold
* Update test doc comments
* Refactor TestBuildIndexes_MaxCountThreshold to not use mock framework
* Rename mocks used in TestBuildIndexes_MaxCountThreshold

* Refactor mockResourceIndex

* Test setting of indexing threshold configs

* Tweak comments, log

* Fix logging in buildEmptyIndex

* Export and reuse TestDocumentBuilderSupplier

* Reuse MockResourceIndex
This commit is contained in:
Arati R.
2025-06-27 14:00:39 +02:00
committed by GitHub
parent 3020794b60
commit 0982cfd9a0
10 changed files with 356 additions and 76 deletions
+217
View File
@@ -2,9 +2,12 @@ package resource
import (
"context"
"testing"
"github.com/grafana/authlib/types"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
@@ -54,3 +57,217 @@ func (m *MockDocumentBuilder) BuildDocument(ctx context.Context, key *resourcepb
}
return args.Get(0).(*IndexableDocument), nil
}
// mockStorageBackend implements StorageBackend for testing
type mockStorageBackend struct {
resourceStats []ResourceStats
}
func (m *mockStorageBackend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]ResourceStats, error) {
var result []ResourceStats
for _, stat := range m.resourceStats {
// Apply the minCount filter like the real implementation does
if stat.Count > int64(minCount) {
result = append(result, stat)
}
}
return result, nil
}
func (m *mockStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (int64, error) {
return 0, nil
}
func (m *mockStorageBackend) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *BackendReadResponse {
return nil
}
func (m *mockStorageBackend) WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) {
return nil, nil
}
func (m *mockStorageBackend) ListIterator(ctx context.Context, req *resourcepb.ListRequest, callback func(ListIterator) error) (int64, error) {
return 0, nil
}
func (m *mockStorageBackend) ListHistory(ctx context.Context, req *resourcepb.ListRequest, callback func(ListIterator) error) (int64, error) {
return 0, nil
}
// mockSearchBackend implements SearchBackend for testing with tracking capabilities
type mockSearchBackend struct {
buildIndexCalls []buildIndexCall
buildEmptyIndexCalls []buildEmptyIndexCall
}
type buildIndexCall struct {
key NamespacedResource
size int64
resourceVersion int64
fields SearchableDocumentFields
}
type buildEmptyIndexCall struct {
key NamespacedResource
size int64 // should be 0 for empty indexes
resourceVersion int64
fields SearchableDocumentFields
}
func (m *mockSearchBackend) GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) {
return nil, nil
}
func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) {
index := &MockResourceIndex{}
index.On("BulkIndex", mock.Anything).Return(nil).Maybe()
index.On("DocCount", mock.Anything, mock.Anything).Return(int64(0), nil).Maybe()
// Call the builder function (required by the contract)
_, err := builder(index)
if err != nil {
return nil, err
}
// Determine if this is an empty index based on size
// Empty indexes are characterized by size == 0
if size == 0 {
// This is an empty index (buildEmptyIndex was called)
m.buildEmptyIndexCalls = append(m.buildEmptyIndexCalls, buildEmptyIndexCall{
key: key,
size: size,
resourceVersion: resourceVersion,
fields: fields,
})
} else {
// This is a normal index (build was called)
m.buildIndexCalls = append(m.buildIndexCalls, buildIndexCall{
key: key,
size: size,
resourceVersion: resourceVersion,
fields: fields,
})
}
return index, nil
}
func (m *mockSearchBackend) TotalDocs() int64 {
return 0
}
func TestBuildIndexes_MaxCountThreshold(t *testing.T) {
tests := []struct {
name string
initMaxSize int
resourceStats []ResourceStats
expectedNormalBuilds []string // expected NamespacedResource strings that should be built normally
expectedEmptyBuilds []string // expected NamespacedResource strings that should be built as empty
}{
{
name: "max count disabled (0) - all resources built normally",
initMaxSize: 0,
resourceStats: []ResourceStats{
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group1", Resource: "resource1"}, Count: 50},
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group1", Resource: "resource2"}, Count: 150},
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group2", Resource: "resource1"}, Count: 250},
},
expectedNormalBuilds: []string{
"ns1/group1/resource1",
"ns1/group1/resource2",
"ns1/group2/resource1",
},
expectedEmptyBuilds: []string{},
},
{
name: "max count 100 - resources above threshold get empty indexes",
initMaxSize: 100,
resourceStats: []ResourceStats{
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group1", Resource: "resource1"}, Count: 50}, // normal build
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group1", Resource: "resource2"}, Count: 150}, // empty build
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group2", Resource: "resource1"}, Count: 250}, // empty build
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group2", Resource: "resource2"}, Count: 80}, // normal build
},
expectedNormalBuilds: []string{
"ns1/group1/resource1",
"ns1/group2/resource2",
},
expectedEmptyBuilds: []string{
"ns1/group1/resource2",
"ns1/group2/resource1",
},
},
{
name: "max count 300 - no resources exceed threshold",
initMaxSize: 300,
resourceStats: []ResourceStats{
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group1", Resource: "resource1"}, Count: 50}, // normal build
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group1", Resource: "resource2"}, Count: 150}, // normal build
{NamespacedResource: NamespacedResource{Namespace: "ns1", Group: "group2", Resource: "resource1"}, Count: 250}, // normal build
},
expectedNormalBuilds: []string{
"ns1/group1/resource1",
"ns1/group1/resource2",
"ns1/group2/resource1",
},
expectedEmptyBuilds: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock implementations
storage := &mockStorageBackend{
resourceStats: tt.resourceStats,
}
search := &mockSearchBackend{
buildIndexCalls: []buildIndexCall{},
buildEmptyIndexCalls: []buildEmptyIndexCall{},
}
supplier := &TestDocumentBuilderSupplier{
GroupsResources: map[string]string{
"group1": "resource1",
"group2": "resource2",
},
}
// Create search support with the specified initMaxSize
opts := SearchOptions{
Backend: search,
Resources: supplier,
WorkerThreads: 1,
InitMinCount: 1, // set min count to default for this test
InitMaxCount: tt.initMaxSize,
}
support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil)
require.NoError(t, err)
require.NotNil(t, support)
// Call buildIndexes
ctx := context.Background()
indexesBuilt, err := support.buildIndexes(ctx, false)
require.NoError(t, err)
// Verify the correct number of indexes were built (normal + empty)
expectedTotal := len(tt.expectedNormalBuilds) + len(tt.expectedEmptyBuilds)
require.Equal(t, expectedTotal, indexesBuilt)
// Verify the correct resources were built normally
actualNormalBuilds := make([]string, len(search.buildIndexCalls))
for i, call := range search.buildIndexCalls {
actualNormalBuilds[i] = call.key.String()
}
require.ElementsMatch(t, tt.expectedNormalBuilds, actualNormalBuilds)
// Verify the correct resources were built as empty indexes
actualEmptyBuilds := make([]string, len(search.buildEmptyIndexCalls))
for i, call := range search.buildEmptyIndexCalls {
actualEmptyBuilds[i] = call.key.String()
// Verify that empty indexes are built with size 0
require.Equal(t, int64(0), call.size, "Empty index should be built with size 0")
}
require.ElementsMatch(t, tt.expectedEmptyBuilds, actualEmptyBuilds)
})
}
}