UnifiedSearch: Introduce a ResourceIndex interface and bleve implementation (#96826)

Co-authored-by: Scott Lepper <scott.lepper@gmail.com>
This commit is contained in:
Ryan McKinley
2024-11-22 16:44:06 +03:00
committed by GitHub
co-authored by Scott Lepper
parent bbae396db4
commit c6848d4b68
20 changed files with 2533 additions and 425 deletions
+26 -5
View File
@@ -159,11 +159,8 @@ func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAcces
return doc
}
func StandardDocumentBuilder() DocumentBuilderInfo {
return DocumentBuilderInfo{
Builder: &standardDocumentBuilder{},
Fields: StandardSearchFields(),
}
func StandardDocumentBuilder() DocumentBuilder {
return &standardDocumentBuilder{}
}
type standardDocumentBuilder struct{}
@@ -295,6 +292,30 @@ func StandardSearchFields() SearchableDocumentFields {
FreeText: true,
},
},
{
Name: SEARCH_FIELD_TAGS,
Type: ResourceTableColumnDefinition_STRING,
IsArray: true,
Description: "Unique tags",
Properties: &ResourceTableColumnDefinition_Properties{
Filterable: true,
},
},
{
Name: SEARCH_FIELD_FOLDER,
Type: ResourceTableColumnDefinition_STRING,
Description: "Kubernetes name for the folder",
},
{
Name: SEARCH_FIELD_RV,
Type: ResourceTableColumnDefinition_INT64,
Description: "resource version",
},
{
Name: SEARCH_FIELD_CREATED,
Type: ResourceTableColumnDefinition_INT64,
Description: "created timestamp", // date?
},
})
if err != nil {
panic("failed to initialize standard search fields")
@@ -12,7 +12,7 @@ import (
func TestStandardDocumentBuilder(t *testing.T) {
ctx := context.Background()
builder := StandardDocumentBuilder().Builder
builder := StandardDocumentBuilder()
body, err := os.ReadFile("testdata/playlist-resource.json")
require.NoError(t, err)
File diff suppressed because it is too large Load Diff
@@ -323,6 +323,7 @@ message WatchEvent {
Resource previous = 4;
}
// This will soon be deprecated/replaced with ResourceSearchRequest
message SearchRequest {
// query string for chosen implementation (currently just bleve)
string query = 1;
@@ -342,6 +343,92 @@ message SearchRequest {
repeated string filters = 10;
}
// Search within a single resource
message ResourceSearchRequest {
message Sort {
string field = 1;
bool desc = 2; // defaults to ascending
}
message Facet {
string field = 1;
int64 limit = 2;
// For now, only term queries, eventually?
// numeric queries
// date queries
}
// The key must include namespace + group + resource
ListOptions options = 1;
// To search additional resource types, add additional keys to this list
// NOTE: queries will only support federation across kinds with common fields
repeated ResourceKey federated = 2;
// When a query exists, it is parsed and used to influence
// query string for chosen implementation (currently just bleve)
// The score is only relevant when a query exists
string query = 3;
// max results
int64 limit = 4;
// where to start the query (eg, From)
int64 offset = 5;
// sorting
repeated Sort sortBy = 6;
// calculate field statistics
map<string,Facet> facet = 7;
// the return fields (empty will return everything)
repeated string fields = 8;
// explain each result (added to the each row)
bool explain = 9;
}
message ResourceSearchResponse {
message Facet {
string field = 1;
// The distinct terms
int64 total = 2;
// The number of documents that do *not* have this field
int64 missing = 3;
// Top term stats
repeated TermFacet terms = 4;
// numeric range
// date range facets
}
message TermFacet {
string term = 1;
int64 count = 2;
}
// Error details
ErrorResult error = 1;
// All results exist within this key
ResourceKey key = 2;
// Query results
ResourceTable results = 3;
// The total hit count
uint64 total_hits = 4;
// indicates how expensive was the query with respect to bytes read
uint64 query_cost = 5;
// maximum score across all fields
double max_score = 6;
// Facet results
map<string,Facet> facet = 7;
}
message GroupBy {
string name = 1;
int64 limit = 2;
@@ -352,6 +439,7 @@ message Group {
int64 count = 2;
}
// This will soon be deprecated/replaced with ResourceSearchResponse
message SearchResponse {
repeated ResourceWrapper items = 1;
repeated Group groups = 2;
+107 -3
View File
@@ -12,6 +12,8 @@ import (
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/authlib/authz"
)
type NamespacedResource struct {
@@ -20,8 +22,52 @@ type NamespacedResource struct {
Resource string
}
// All fields are set
func (s *NamespacedResource) Valid() bool {
return s.Namespace != "" && s.Group != "" && s.Resource != ""
}
type ResourceIndex interface {
// Add a document to the index. Note it may not be searchable until after flush is called
Write(doc *IndexableDocument) error
// Mark a resource as deleted. Note it may not be searchable until after flush is called
Delete(key *ResourceKey) error
// Make sure any changes to the index are flushed and available in the next search/origin calls
Flush() error
// Search within a namespaced resource
// When working with federated queries, the additional indexes will be passed in explicitly
Search(ctx context.Context, access authz.AccessClient, req *ResourceSearchRequest, federate []ResourceIndex) (*ResourceSearchResponse, error)
// Execute an origin query -- access control is not not checked for each item
// NOTE: this will likely be used for provisioning, or it will be removed
Origin(ctx context.Context, req *OriginRequest) (*OriginResponse, error)
}
// SearchBackend contains the technology specific logic to support search
type SearchBackend interface {
// TODO
// This will return nil if the key does not exist
GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error)
// Build an index from scratch
BuildIndex(ctx context.Context,
key NamespacedResource,
// When the size is known, it will be passed along here
// Depending on the size, the backend may choose different options (eg: memory vs disk)
size int64,
// The last known resource version (can be used to know that nothing has changed)
resourceVersion int64,
// The non-standard index fields
fields SearchableDocumentFields,
// The builder will write all documents before returning
builder func(index ResourceIndex) (int64, error),
) (ResourceIndex, error)
}
const tracingPrexfixSearch = "unified_search."
@@ -119,7 +165,7 @@ func (s *searchSupport) init(ctx context.Context) error {
return nil
}
func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (any, int64, error) {
func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (ResourceIndex, int64, error) {
_, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build")
defer span.End()
@@ -127,10 +173,57 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size
if err != nil {
return nil, 0, err
}
fields := s.builders.GetFields(nsr)
s.log.Debug(fmt.Sprintf("TODO, build %+v (size:%d, rv:%d) // builder:%+v\n", nsr, size, rv, builder))
return nil, 0, nil
key := &ResourceKey{
Group: nsr.Group,
Resource: nsr.Resource,
Namespace: nsr.Namespace,
}
index, err := s.search.BuildIndex(ctx, nsr, size, rv, fields, func(index ResourceIndex) (int64, error) {
rv, err = s.storage.ListIterator(ctx, &ListRequest{
Limit: 1000000000000, // big number
Options: &ListOptions{
Key: key,
},
}, func(iter ListIterator) error {
for iter.Next() {
if err = iter.Error(); err != nil {
return err
}
// Update the key name
// Or should we read it from the body?
key.Name = iter.Name()
// Convert it to an indexable document
doc, err := builder.BuildDocument(ctx, key, iter.ResourceVersion(), iter.Value())
if err != nil {
return err
}
// And finally write it to the index
if err = index.Write(doc); err != nil {
return err
}
}
return err
})
return rv, err
})
if err != nil {
return nil, 0, err
}
if err == nil {
err = index.Flush()
}
// rv is the last RV we read. when watching, we must add all events since that time
return index, rv, err
}
type builderCache struct {
@@ -140,6 +233,9 @@ type builderCache struct {
// Possible blob support
blob BlobSupport
// searchable fields initialized once on startup
fields map[schema.GroupResource]SearchableDocumentFields
// lookup by group, then resource (namespace)
// This is only modified at startup, so we do not need mutex for access
lookup map[string]map[string]DocumentBuilderInfo
@@ -151,6 +247,7 @@ type builderCache struct {
func newBuilderCache(cfg []DocumentBuilderInfo, nsCacheSize int, ttl time.Duration) (*builderCache, error) {
cache := &builderCache{
fields: make(map[schema.GroupResource]SearchableDocumentFields),
lookup: make(map[string]map[string]DocumentBuilderInfo),
ns: expirable.NewLRU[NamespacedResource, DocumentBuilder](nsCacheSize, nil, ttl),
}
@@ -173,10 +270,17 @@ func newBuilderCache(cfg []DocumentBuilderInfo, nsCacheSize int, ttl time.Durati
cache.lookup[b.GroupResource.Group] = g
}
g[b.GroupResource.Resource] = b
// Any custom fields
cache.fields[b.GroupResource] = b.Fields
}
return cache, nil
}
func (s *builderCache) GetFields(key NamespacedResource) SearchableDocumentFields {
return s.fields[schema.GroupResource{Group: key.Group, Resource: key.Resource}]
}
// context is typically background. Holds an LRU cache for a
func (s *builderCache) get(ctx context.Context, key NamespacedResource) (DocumentBuilder, error) {
g, ok := s.lookup[key.Group]
+48
View File
@@ -7,13 +7,18 @@ import (
"encoding/json"
"fmt"
"io"
"os"
reflect "reflect"
"strconv"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
)
@@ -74,6 +79,10 @@ func (x *ResourceTable) ToK8s() (metav1.Table, error) {
}
} else if r.Key != nil {
obj := &metav1.PartialObjectMetadata{
TypeMeta: metav1.TypeMeta{
Kind: r.Key.Resource, // :(
APIVersion: r.Key.Group, // :(
},
ObjectMeta: metav1.ObjectMeta{
Name: r.Key.Name,
Namespace: r.Key.Namespace,
@@ -102,6 +111,8 @@ type TableBuilder struct {
hasDuplicateNames bool
}
type ResourceColumnEncoder = func(v any) ([]byte, error)
func NewTableBuilder(cols []*ResourceTableColumnDefinition) (*TableBuilder, error) {
table := &TableBuilder{
ResourceTable: ResourceTable{
@@ -124,6 +135,15 @@ func NewTableBuilder(cols []*ResourceTableColumnDefinition) (*TableBuilder, erro
return table, err
}
func (x *TableBuilder) Encoders() []ResourceColumnEncoder {
encoders := make([]ResourceColumnEncoder, len(x.Columns))
for i, f := range x.Columns {
v := x.lookup[f.Name]
encoders[i] = v.Encode
}
return encoders
}
func (x *TableBuilder) AddRow(key *ResourceKey, rv int64, vals map[string]any) error {
row := &ResourceTableRow{
Key: key,
@@ -395,6 +415,8 @@ func (x *resourceTableColumn) Encode(v any) ([]byte, error) {
f = int64(typed)
case float32:
f = int64(typed)
case float64:
f = int64(typed)
case uint64:
f = int64(typed)
case uint:
@@ -547,3 +569,29 @@ func (x *resourceTableColumn) Decode(buff []byte) (any, error) {
}
return v, err
}
// AssertTableSnapshot will match a ResourceTable vs the saved value
func AssertTableSnapshot(t *testing.T, path string, table *ResourceTable) {
t.Helper()
k8sTable, err := table.ToK8s()
require.NoError(t, err, "unable to create table response", path)
actual, err := json.MarshalIndent(k8sTable, "", " ")
require.NoError(t, err, "unable to write table json", path)
// Safe to disable, this is a test.
// nolint:gosec
expected, err := os.ReadFile(path)
if err != nil || len(expected) < 1 {
assert.Fail(t, "missing file: %s", path)
} else if assert.JSONEq(t, string(expected), string(actual)) {
return // everything is OK
}
// Write the snapshot
// Safe to disable, this is a test.
// nolint:gosec
err = os.WriteFile(path, actual, 0600)
require.NoError(t, err)
fmt.Printf("Updated table snapshot: %s\n", path)
}
@@ -1,44 +1,15 @@
package resource
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// AssertTableSnapshot will match a ResourceTable vs the saved value
func AssertTableSnapshot(t *testing.T, path string, table *ResourceTable) {
t.Helper()
k8sTable, err := table.ToK8s()
require.NoError(t, err, "unable to create table response", path)
actual, err := json.MarshalIndent(k8sTable, "", " ")
require.NoError(t, err, "unable to write table json", path)
// Safe to disable, this is a test.
// nolint:gosec
expected, err := os.ReadFile(path)
if err != nil || len(expected) < 1 {
assert.Fail(t, "missing file")
} else if assert.JSONEq(t, string(expected), string(actual)) {
return // everything is OK
}
// Write the snapshot
// Safe to disable, this is a test.
// nolint:gosec
err = os.WriteFile(path, actual, 0600)
require.NoError(t, err)
fmt.Printf("Updated table snapshot: %s\n", path)
}
func TestTableFormat(t *testing.T) {
columns := []*ResourceTableColumnDefinition{
{
@@ -41,6 +41,8 @@
]
],
"object": {
"kind": "xyz",
"apiVersion": "ggg",
"metadata": {
"name": "aaa",
"namespace": "default",
@@ -60,6 +62,8 @@
]
],
"object": {
"kind": "xyz",
"apiVersion": "ggg",
"metadata": {
"name": "bbb",
"namespace": "default",
+547
View File
@@ -0,0 +1,547 @@
package search
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"sync"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/search"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace"
"k8s.io/apimachinery/pkg/selection"
"github.com/grafana/authlib/authz"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
const tracingPrexfixBleve = "unified_search.bleve."
var _ resource.SearchBackend = &bleveBackend{}
var _ resource.ResourceIndex = &bleveIndex{}
type bleveOptions struct {
// The root folder where file objects are saved
Root string
// The resource count where values switch from memory to file based
FileThreshold int64
// How big should a batch get before flushing
// ?? not totally sure the units
BatchSize int
}
type bleveBackend struct {
tracer trace.Tracer
log *slog.Logger
opts bleveOptions
// cache info
cache map[resource.NamespacedResource]*bleveIndex
cacheMu sync.RWMutex
}
func NewBleveBackend(opts bleveOptions, tracer trace.Tracer, reg prometheus.Registerer) *bleveBackend {
b := &bleveBackend{
log: slog.Default().With("logger", "bleve-backend"),
tracer: tracer,
cache: make(map[resource.NamespacedResource]*bleveIndex),
opts: opts,
}
if reg != nil {
b.log.Info("TODO, register metrics collectors!")
}
return b
}
// This will return nil if the key does not exist
func (b *bleveBackend) GetIndex(ctx context.Context, key resource.NamespacedResource) (resource.ResourceIndex, error) {
b.cacheMu.RLock()
defer b.cacheMu.RUnlock()
idx, ok := b.cache[key]
if ok {
return idx, nil
}
return nil, nil
}
// Build an index from scratch
func (b *bleveBackend) BuildIndex(ctx context.Context,
key resource.NamespacedResource,
// When the size is known, it will be passed along here
// Depending on the size, the backend may choose different options (eg: memory vs disk)
size int64,
// The last known resource version can be used to know that we can skip calling the builder
resourceVersion int64,
// the non-standard searchable fields
fields resource.SearchableDocumentFields,
// The builder will write all documents before returning
builder func(index resource.ResourceIndex) (int64, error),
) (resource.ResourceIndex, error) {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
_, span := b.tracer.Start(ctx, tracingPrexfixBleve+"BuildIndex")
defer span.End()
var err error
var index bleve.Index
mapper := getBleveMappings(fields)
if size > b.opts.FileThreshold {
dir := filepath.Join(b.opts.Root, key.Namespace, fmt.Sprintf("%s.%s", key.Resource, key.Group))
index, err = bleve.New(dir, mapper)
if err == nil {
b.log.Info("TODO, check last RV so we can see if the numbers have changed", "dir", dir)
}
} else {
index, err = bleve.NewMemOnly(mapper)
}
if err != nil {
return nil, err
}
// Batch all the changes
idx := &bleveIndex{
key: key,
index: index,
batch: index.NewBatch(),
batchSize: b.opts.BatchSize,
fields: fields,
standard: resource.StandardSearchFields(),
}
idx.allFields, err = getAllFields(idx.standard, fields)
if err != nil {
return nil, err
}
_, err = builder(idx)
if err != nil {
return nil, err
}
// Flush the batch
err = idx.Flush()
if err != nil {
return nil, err
}
b.cache[key] = idx
return idx, nil
}
type bleveIndex struct {
key resource.NamespacedResource
index bleve.Index
standard resource.SearchableDocumentFields
fields resource.SearchableDocumentFields
// The values returned with all
allFields []*resource.ResourceTableColumnDefinition
// only valid in single thread
batch *bleve.Batch
batchSize int // ??? not totally sure the units here
}
// Write implements resource.DocumentIndex.
func (b *bleveIndex) Write(v *resource.IndexableDocument) error {
// remove references (for now!)
v.References = nil
if b.batch != nil {
err := b.batch.Index(v.Key.SearchID(), v)
if err != nil {
return err
}
if b.batch.Size() > b.batchSize {
err = b.index.Batch(b.batch)
b.batch.Reset() // clear the batch
}
return err // nil
}
return b.index.Index(v.Key.SearchID(), v)
}
// Delete implements resource.DocumentIndex.
func (b *bleveIndex) Delete(key *resource.ResourceKey) error {
if b.batch != nil {
return fmt.Errorf("unexpected delete while building batch")
}
return b.index.Delete(key.SearchID())
}
// Flush implements resource.DocumentIndex.
func (b *bleveIndex) Flush() (err error) {
if b.batch != nil {
err = b.index.Batch(b.batch)
b.batch.Reset()
b.batch = nil
}
return err
}
// Origin implements resource.DocumentIndex.
func (b *bleveIndex) Origin(ctx context.Context, req *resource.OriginRequest) (*resource.OriginResponse, error) {
panic("unimplemented")
}
// Search implements resource.DocumentIndex.
func (b *bleveIndex) Search(
ctx context.Context,
access authz.AccessClient,
req *resource.ResourceSearchRequest,
federate []resource.ResourceIndex, // For federated queries, these will match the values in req.federate
) (*resource.ResourceSearchResponse, error) {
if req.Options == nil || req.Options.Key == nil {
return &resource.ResourceSearchResponse{
Error: resource.NewBadRequestError("missing query key"),
}, nil
}
response := &resource.ResourceSearchResponse{
Error: b.verifyKey(req.Options.Key),
}
if response.Error != nil {
return response, nil
}
// Verifies the index federation
index, err := b.getIndex(req, federate)
if err != nil {
return nil, err
}
// convert protobuf request to bleve request
searchrequest, e := toBleveSearchRequest(req, access)
if e != nil {
response.Error = e
return response, nil
}
// Show all fields when nothing is selected
if len(searchrequest.Fields) < 1 && req.Limit > 0 {
f, err := b.index.Fields()
if err != nil {
return nil, err
}
searchrequest.Fields = f
}
res, err := index.Search(searchrequest)
if err != nil {
return nil, err
}
response.TotalHits = res.Total
response.QueryCost = res.Cost
response.MaxScore = res.MaxScore
response.Results, err = b.hitsToTable(searchrequest.Fields, res.Hits, req.Explain)
if err != nil {
return nil, err
}
// Write frame as JSON
//response.Frame, err = frame.MarshalJSON()
if err != nil {
return nil, err
}
// parse the facet fields
for k, v := range res.Facets {
f := &resource.ResourceSearchResponse_Facet{
Field: v.Field,
Total: int64(v.Total),
Missing: int64(v.Missing),
}
if v.Terms != nil {
for _, t := range v.Terms.Terms() {
f.Terms = append(f.Terms, &resource.ResourceSearchResponse_TermFacet{
Term: t.Term,
Count: int64(t.Count),
})
}
}
if response.Facet == nil {
response.Facet = make(map[string]*resource.ResourceSearchResponse_Facet)
}
response.Facet[k] = f
}
return response, nil
}
// make sure the request key matches the index
func (b *bleveIndex) verifyKey(key *resource.ResourceKey) *resource.ErrorResult {
if key.Namespace != b.key.Namespace {
return resource.NewBadRequestError("namespace mismatch (expected " + b.key.Namespace + ")")
}
if key.Group != b.key.Group {
return resource.NewBadRequestError("group mismatch (expected " + b.key.Group + ")")
}
if key.Resource != b.key.Resource {
return resource.NewBadRequestError("resource mismatch (expected " + b.key.Resource + ")")
}
return nil
}
func (b *bleveIndex) getIndex(
req *resource.ResourceSearchRequest,
federate []resource.ResourceIndex,
) (bleve.Index, error) {
if len(req.Federated) != len(federate) {
return nil, fmt.Errorf("federation is misconfigured")
}
// Search across resources using
// https://blevesearch.com/docs/IndexAlias/
if len(federate) > 0 {
all := []bleve.Index{b.index}
for i, extra := range federate {
typedindex, ok := extra.(*bleveIndex)
if !ok {
return nil, fmt.Errorf("federated indexes must be the same type")
}
if typedindex.verifyKey(req.Federated[i]) != nil {
return nil, fmt.Errorf("federated index keys do not match")
}
all = append(all, typedindex.index)
}
return bleve.NewIndexAlias(all...), nil
}
return b.index, nil
}
func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.AccessClient) (*bleve.SearchRequest, *resource.ErrorResult) {
searchrequest := &bleve.SearchRequest{
Fields: req.Fields,
Size: int(req.Limit),
From: int(req.Offset),
Explain: req.Explain,
}
// Currently everything is within an AND query
queries := []query.Query{}
if len(req.Options.Labels) > 0 {
for _, v := range req.Options.Labels {
q, err := requirementQuery(v, "labels.")
if err != nil {
return nil, err
}
queries = append(queries, q)
}
}
if len(req.Options.Fields) > 0 {
for _, v := range req.Options.Fields {
q, err := requirementQuery(v, "")
if err != nil {
return nil, err
}
queries = append(queries, q)
}
}
if req.Query != "" {
// ??? Should expose the full power of query parsing here?
// it is great for exploration, but also hard to change in the future
q := bleve.NewQueryStringQuery(req.Query)
queries = append(queries, q)
}
if access != nil {
// TODO AUTHZ!!!!
// Need to add an authz filter into the mix
// See: https://github.com/grafana/grafana/blob/v11.3.0/pkg/services/searchV2/bluge.go
// NOTE, we likely want to pass in the already called checker because the resource server
// will first need to check if we can see anything (or everything!) for this resource
fmt.Printf("TODO... check authorization")
}
switch len(queries) {
case 0:
searchrequest.Query = bleve.NewMatchAllQuery()
case 1:
searchrequest.Query = queries[0]
default:
searchrequest.Query = bleve.NewConjunctionQuery(queries...) // AND
}
for k, v := range req.Facet {
if searchrequest.Facets == nil {
searchrequest.Facets = make(bleve.FacetsRequest)
}
searchrequest.Facets[k] = bleve.NewFacetRequest(v.Field, int(v.Limit))
}
// Add the sort fields
for _, sort := range req.SortBy {
// hardcoded (for now)
if strings.HasPrefix(sort.Field, "stats.") {
searchrequest.Sort = append(searchrequest.Sort, &search.SortField{
Field: sort.Field,
Desc: sort.Desc,
Type: search.SortFieldAsNumber, // force for now!
Mode: search.SortFieldDefault, // ???
Missing: search.SortFieldMissingLast,
})
continue
}
// Default support
input := sort.Field
if sort.Desc {
input = "-" + sort.Field
}
s := search.ParseSearchSortString(input)
searchrequest.Sort = append(searchrequest.Sort, s)
}
// Always sort by *something*, otherwise the order is unstable
if len(searchrequest.Sort) == 0 {
searchrequest.Sort = append(searchrequest.Sort, &search.SortDocID{
Desc: false,
})
}
return searchrequest, nil
}
// Convert a "requirement" into a bleve query
func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *resource.ErrorResult) {
switch selection.Operator(req.Operator) {
case selection.Equals, selection.DoubleEquals:
if len(req.Values) != 1 {
return nil, resource.NewBadRequestError("equals query can have one value")
}
q := query.NewMatchQuery(req.Values[0])
q.FieldVal = prefix + req.Key
return q, nil
case selection.NotEquals:
case selection.DoesNotExist:
case selection.GreaterThan:
case selection.LessThan:
case selection.Exists:
case selection.In:
case selection.NotIn:
}
return nil, resource.NewBadRequestError(
fmt.Sprintf("unsupported query operation (%s %s %v)", req.Key, req.Operator, req.Values),
)
}
func (b *bleveIndex) hitsToTable(selectFields []string, hits search.DocumentMatchCollection, explain bool) (*resource.ResourceTable, error) {
fields := []*resource.ResourceTableColumnDefinition{}
for _, name := range selectFields {
if name == "_all" {
fields = b.allFields
break
}
f := b.standard.Field(name)
if f == nil && b.fields != nil {
f = b.fields.Field(name)
}
if f == nil {
// Labels as a string
if strings.HasPrefix(name, "labels.") {
f = &resource.ResourceTableColumnDefinition{
Name: name,
Type: resource.ResourceTableColumnDefinition_STRING,
}
}
// return nil, fmt.Errorf("unknown response field: " + name)
if f == nil {
continue // OK for now
}
}
fields = append(fields, f)
}
if explain {
fields = append(fields, b.standard.Field(resource.SEARCH_FIELD_EXPLAIN))
}
builder, err := resource.NewTableBuilder(fields)
if err != nil {
return nil, err
}
encoders := builder.Encoders()
table := &resource.ResourceTable{
Columns: fields,
Rows: make([]*resource.ResourceTableRow, hits.Len()),
}
for rowID, match := range hits {
row := &resource.ResourceTableRow{
Key: &resource.ResourceKey{},
Cells: make([][]byte, len(fields)),
}
table.Rows[rowID] = row
err := row.Key.ReadSearchID(match.ID)
if err != nil {
return nil, err
}
for i, f := range fields {
if f.Name == resource.SEARCH_FIELD_ID {
row.Cells[i] = []byte(match.ID)
continue
}
// QUICK QUICK... more options yes
v := match.Fields[f.Name]
if v != nil {
// Encode the value to protobuf
row.Cells[i], err = encoders[i](v)
if err != nil {
return nil, fmt.Errorf("error encoding (row:%d/col:%d) %v %w", rowID, i, v, err)
}
}
}
}
return table, nil
}
func getAllFields(standard resource.SearchableDocumentFields, custom resource.SearchableDocumentFields) ([]*resource.ResourceTableColumnDefinition, error) {
fields := []*resource.ResourceTableColumnDefinition{
standard.Field(resource.SEARCH_FIELD_ID),
standard.Field(resource.SEARCH_FIELD_TITLE),
standard.Field(resource.SEARCH_FIELD_TAGS),
standard.Field(resource.SEARCH_FIELD_FOLDER),
standard.Field(resource.SEARCH_FIELD_RV),
standard.Field(resource.SEARCH_FIELD_CREATED),
}
if custom != nil {
for _, name := range custom.Fields() {
f := custom.Field(name)
if f.Priority > 10 {
continue
}
fields = append(fields, f)
}
}
for _, field := range fields {
if field == nil {
return nil, fmt.Errorf("invalid all field")
}
}
return fields, nil
}
@@ -0,0 +1,68 @@
package search
import (
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
"github.com/blevesearch/bleve/v2/mapping"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
func getBleveMappings(fields resource.SearchableDocumentFields) mapping.IndexMapping {
mapper := bleve.NewIndexMapping()
mapper.DefaultMapping = getBleveDocMappings(fields)
return mapper
}
func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentMapping {
mapper := bleve.NewDocumentStaticMapping()
mapper.AddFieldMapping(&mapping.FieldMapping{
Name: "title",
Type: "text",
// TODO - if we don't want title to be a keyword, we can use this
// set the title field to use keyword analyzer so it sorts by the whole phrase
// https://github.com/blevesearch/bleve/issues/417#issuecomment-245273022
Analyzer: keyword.Name,
Store: true,
Index: true,
IncludeTermVectors: true,
IncludeInAll: true,
DocValues: false,
})
mapper.AddFieldMapping(&mapping.FieldMapping{
Name: "description",
Type: "text",
Store: true,
Index: true,
IncludeTermVectors: false,
IncludeInAll: false,
DocValues: false,
})
mapper.AddFieldMapping(&mapping.FieldMapping{
Name: "tags",
Type: "text",
Analyzer: keyword.Name,
Store: true,
Index: true,
IncludeTermVectors: false,
IncludeInAll: false,
DocValues: false,
})
mapper.AddFieldMapping(&mapping.FieldMapping{
Name: "folder",
Type: "text",
Analyzer: keyword.Name,
Store: true,
Index: true,
IncludeTermVectors: false,
IncludeInAll: false,
DocValues: true, // will be needed for authz client
})
mapper.Dynamic = true
return mapper
}
@@ -0,0 +1,46 @@
package search
import (
"fmt"
"testing"
"github.com/blevesearch/bleve/v2/document"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
func TestDocumentMapping(t *testing.T) {
mappings := getBleveMappings(nil)
data := resource.IndexableDocument{
Title: "title",
Description: "descr",
Tags: []string{"a", "b"},
Created: 12345,
Folder: "xyz",
CreatedBy: "user:ryan",
Labels: map[string]string{
"a": "b",
"x": "y",
},
RV: 1234,
RepoInfo: &utils.ResourceRepositoryInfo{
Name: "nnn",
Path: "ppp",
Hash: "hhh",
},
}
doc := document.NewDocument("id")
err := mappings.MapDocument(doc, data)
require.NoError(t, err)
for _, f := range doc.Fields {
fmt.Printf("%s = %+v\n", f.Name(), f.Value())
}
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
fmt.Printf("DOC: size %d\n", doc.Size())
require.Equal(t, 15, len(doc.Fields))
}
+289
View File
@@ -0,0 +1,289 @@
package search
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
func TestBleveBackend(t *testing.T) {
dashboardskey := &resource.ResourceKey{
Namespace: "default",
Group: "dashboard.grafana.app",
Resource: "dashboards",
}
folderKey := &resource.ResourceKey{
Namespace: dashboardskey.Namespace,
Group: "folder.grafana.app",
Resource: "folders",
}
tmpdir, err := os.CreateTemp("", "bleve-test")
require.NoError(t, err)
backend := NewBleveBackend(
bleveOptions{
Root: tmpdir.Name(),
FileThreshold: 5, // with more than 5 items we create a file on disk
},
tracing.NewNoopTracerService(),
nil,
)
rv := int64(10)
ctx := context.Background()
var dashboardsIndex resource.ResourceIndex
var foldersIndex resource.ResourceIndex
t.Run("build dashboards", func(t *testing.T) {
key := dashboardskey
info, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
return &DashboardDocumentBuilder{
Namespace: namespace,
Blob: blob,
Stats: NewDashboardStatsLookup(nil), // empty stats
DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{}}),
}, nil
})
require.NoError(t, err)
index, err := backend.BuildIndex(ctx, resource.NamespacedResource{
Namespace: key.Namespace,
Group: key.Group,
Resource: key.Resource,
}, 2, rv, info.Fields, func(index resource.ResourceIndex) (int64, error) {
_ = index.Write(&resource.IndexableDocument{
RV: 1,
Key: &resource.ResourceKey{
Name: "aaa",
Namespace: "ns",
Group: "g",
Resource: "dash",
},
Title: "bbb (dash)",
Folder: "xxx",
Fields: map[string]any{
DASHBOARD_LEGACY_ID: 12,
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
DASHBOARD_ERRORS_TODAY: 25,
},
Tags: []string{"aa", "bb"},
})
_ = index.Write(&resource.IndexableDocument{
RV: 2,
Key: &resource.ResourceKey{
Name: "bbb",
Namespace: "ns",
Group: "g",
Resource: "dash",
},
Title: "aaa (dash)",
Folder: "xxx",
Fields: map[string]any{
DASHBOARD_LEGACY_ID: 12,
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
DASHBOARD_ERRORS_TODAY: 40,
},
Tags: []string{"aa"},
Labels: map[string]string{
"region": "east",
},
})
_ = index.Write(&resource.IndexableDocument{
RV: 3,
Key: &resource.ResourceKey{
Name: "ccc",
Namespace: "ns",
Group: "g",
Resource: "dash",
},
Title: "ccc (dash)",
Folder: "xxx",
Fields: map[string]any{
DASHBOARD_LEGACY_ID: 12,
},
Tags: []string{"aa"},
Labels: map[string]string{
"region": "west",
},
})
return rv, nil
})
require.NoError(t, err)
require.NotNil(t, index)
dashboardsIndex = index
rsp, err := index.Search(ctx, nil, &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: key,
},
Limit: 100000,
SortBy: []*resource.ResourceSearchRequest_Sort{
{Field: "title", Desc: true}, // ccc,bbb,aaa
},
Facet: map[string]*resource.ResourceSearchRequest_Facet{
"tags": {
Field: "tags",
Limit: 100,
},
},
}, nil)
require.NoError(t, err)
require.Nil(t, rsp.Error)
require.NotNil(t, rsp.Results)
require.NotNil(t, rsp.Facet)
// Match the results
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-dashboard.json"), rsp.Results)
// Get the tags facets
facet, ok := rsp.Facet["tags"]
require.True(t, ok)
disp, err := json.MarshalIndent(facet, "", " ")
require.NoError(t, err)
//fmt.Printf("%s\n", disp)
require.JSONEq(t, `{
"field": "tags",
"total": 4,
"terms": [
{
"term": "aa",
"count": 3
},
{
"term": "bb",
"count": 1
}
]
}`, string(disp))
})
t.Run("build folders", func(t *testing.T) {
key := folderKey
var fields resource.SearchableDocumentFields
index, err := backend.BuildIndex(ctx, resource.NamespacedResource{
Namespace: key.Namespace,
Group: key.Group,
Resource: key.Resource,
}, 2, rv, fields, func(index resource.ResourceIndex) (int64, error) {
_ = index.Write(&resource.IndexableDocument{
RV: 1,
Key: &resource.ResourceKey{
Name: "zzz",
Namespace: "ns",
Group: "g",
Resource: "folder",
},
Title: "zzz (folder)",
})
_ = index.Write(&resource.IndexableDocument{
RV: 2,
Key: &resource.ResourceKey{
Name: "yyy",
Namespace: "ns",
Group: "g",
Resource: "folder",
},
Title: "yyy (folder)",
Labels: map[string]string{
"region": "west",
},
})
return rv, nil
})
require.NoError(t, err)
require.NotNil(t, index)
foldersIndex = index
rsp, err := index.Search(ctx, nil, &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: key,
},
Limit: 100000,
}, nil)
require.NoError(t, err)
require.Nil(t, rsp.Error)
require.NotNil(t, rsp.Results)
require.Nil(t, rsp.Facet)
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-folder.json"), rsp.Results)
})
t.Run("simple federation", func(t *testing.T) {
// The other tests must run first to build the indexes
require.NotNil(t, dashboardsIndex)
require.NotNil(t, foldersIndex)
// Use a federated query to get both results together, sorted by title
rsp, err := dashboardsIndex.Search(ctx, nil, &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardskey,
},
Fields: []string{
"title", "_id",
},
Federated: []*resource.ResourceKey{
folderKey, // This will join in the
},
Limit: 100000,
SortBy: []*resource.ResourceSearchRequest_Sort{
{Field: "title", Desc: false},
},
Facet: map[string]*resource.ResourceSearchRequest_Facet{
"region": {
Field: "labels.region",
Limit: 100,
},
},
}, []resource.ResourceIndex{foldersIndex}) // << note the folder index matches the federation request
require.NoError(t, err)
require.Nil(t, rsp.Error)
require.NotNil(t, rsp.Results)
require.NotNil(t, rsp.Facet)
// Sorted across two indexes
sorted := []string{}
for _, row := range rsp.Results.Rows {
sorted = append(sorted, string(row.Cells[0]))
}
require.Equal(t, []string{
"aaa (dash)",
"bbb (dash)",
"ccc (dash)",
"yyy (folder)",
"zzz (folder)",
}, sorted)
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-federated.json"), rsp.Results)
facet, ok := rsp.Facet["region"]
require.True(t, ok)
disp, err := json.MarshalIndent(facet, "", " ")
require.NoError(t, err)
// fmt.Printf("%s\n", disp)
// NOTE, the west values come from *both* dashboards and folders
require.JSONEq(t, `{
"field": "labels.region",
"total": 3,
"missing": 2,
"terms": [
{
"term": "west",
"count": 2
},
{
"term": "east",
"count": 1
}
]
}`, string(disp))
})
}
+5 -1
View File
@@ -26,7 +26,11 @@ func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBui
})
return []resource.DocumentBuilderInfo{
resource.StandardDocumentBuilder(),
// The default builder
resource.DocumentBuilderInfo{
Builder: resource.StandardDocumentBuilder(),
},
// Dashboard builder
dashboards,
}, err
}
+1 -1
View File
@@ -80,7 +80,7 @@ func TestDashboardDocumentBuilder(t *testing.T) {
})
// Standard
builder = resource.StandardDocumentBuilder().Builder
builder = resource.StandardDocumentBuilder()
doSnapshotTests(t, builder, "folder", key, []string{
"aaa",
"bbb",
@@ -0,0 +1,143 @@
{
"metadata": {},
"columnDefinitions": [
{
"name": "_id",
"type": "string",
"format": "",
"description": "Unique Identifier. {namespace}/{group}/{resource}/{name}",
"priority": 0
},
{
"name": "title",
"type": "string",
"format": "",
"description": "Display name for the resource",
"priority": 0
},
{
"name": "tags",
"type": "string",
"format": "",
"description": "Unique tags",
"priority": 0
},
{
"name": "folder",
"type": "string",
"format": "",
"description": "Kubernetes name for the folder",
"priority": 0
},
{
"name": "rv",
"type": "number",
"format": "int64",
"description": "resource version",
"priority": 0
},
{
"name": "created",
"type": "number",
"format": "int64",
"description": "created timestamp",
"priority": 0
},
{
"name": "schema_version",
"type": "number",
"format": "int32",
"description": "Numeric version saying when the schema was saved",
"priority": 0
},
{
"name": "link_count",
"type": "number",
"format": "int32",
"description": "How many links appear on the page",
"priority": 0
},
{
"name": "panel_types",
"type": "string",
"format": "",
"description": "How many links appear on the page",
"priority": 0
}
],
"rows": [
{
"cells": [
"ns/g/dash/ccc",
"ccc (dash)",
[
"aa"
],
"xxx",
3,
0,
null,
null,
null
],
"object": {
"kind": "dash",
"apiVersion": "g",
"metadata": {
"name": "ccc",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"ns/g/dash/aaa",
"bbb (dash)",
[
"aa",
"bb"
],
"xxx",
1,
0,
null,
null,
null
],
"object": {
"kind": "dash",
"apiVersion": "g",
"metadata": {
"name": "aaa",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"ns/g/dash/bbb",
"aaa (dash)",
[
"aa"
],
"xxx",
2,
0,
null,
null,
null
],
"object": {
"kind": "dash",
"apiVersion": "g",
"metadata": {
"name": "bbb",
"namespace": "ns",
"creationTimestamp": null
}
}
}
]
}
@@ -0,0 +1,96 @@
{
"metadata": {},
"columnDefinitions": [
{
"name": "title",
"type": "string",
"format": "",
"description": "Display name for the resource",
"priority": 0
},
{
"name": "_id",
"type": "string",
"format": "",
"description": "Unique Identifier. {namespace}/{group}/{resource}/{name}",
"priority": 0
}
],
"rows": [
{
"cells": [
"aaa (dash)",
"ns/g/dash/bbb"
],
"object": {
"kind": "dash",
"apiVersion": "g",
"metadata": {
"name": "bbb",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"bbb (dash)",
"ns/g/dash/aaa"
],
"object": {
"kind": "dash",
"apiVersion": "g",
"metadata": {
"name": "aaa",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"ccc (dash)",
"ns/g/dash/ccc"
],
"object": {
"kind": "dash",
"apiVersion": "g",
"metadata": {
"name": "ccc",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"yyy (folder)",
"ns/g/folder/yyy"
],
"object": {
"kind": "folder",
"apiVersion": "g",
"metadata": {
"name": "yyy",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"zzz (folder)",
"ns/g/folder/zzz"
],
"object": {
"kind": "folder",
"apiVersion": "g",
"metadata": {
"name": "zzz",
"namespace": "ns",
"creationTimestamp": null
}
}
}
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"metadata": {},
"columnDefinitions": [
{
"name": "_id",
"type": "string",
"format": "",
"description": "Unique Identifier. {namespace}/{group}/{resource}/{name}",
"priority": 0
},
{
"name": "title",
"type": "string",
"format": "",
"description": "Display name for the resource",
"priority": 0
},
{
"name": "tags",
"type": "string",
"format": "",
"description": "Unique tags",
"priority": 0
},
{
"name": "folder",
"type": "string",
"format": "",
"description": "Kubernetes name for the folder",
"priority": 0
},
{
"name": "rv",
"type": "number",
"format": "int64",
"description": "resource version",
"priority": 0
},
{
"name": "created",
"type": "number",
"format": "int64",
"description": "created timestamp",
"priority": 0
}
],
"rows": [
{
"cells": [
"ns/g/folder/yyy",
"yyy (folder)",
null,
null,
2,
0
],
"object": {
"kind": "folder",
"apiVersion": "g",
"metadata": {
"name": "yyy",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"ns/g/folder/zzz",
"zzz (folder)",
null,
null,
1,
0
],
"object": {
"kind": "folder",
"apiVersion": "g",
"metadata": {
"name": "zzz",
"namespace": "ns",
"creationTimestamp": null
}
}
}
]
}