From 0dbd8d6963a94df9111284db8224b2b56b06bf20 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 6 Oct 2026 10:51:38 +0200 Subject: [PATCH] feat(search): add metric aggregations sum/min/max/avg over a numeric field: bleve folds the accumulators from doc values, OpenSearch reads them from a stats aggregation, the service layer reduces them to the value of the kind after the cross-space merge. The proto gains MetricDefinition, MetricKind and Metric, which travels as accumulators (sum, count, min, max) so the merge works whatever the kind; value is set after the merge and absent for a metric without a single value. A metric is only valid on a numeric field, and only one of bucketDefinition and metricDefinition at a time. AGG-06, 18 and 21 in the parity suite. --- .../opencloud/services/search/v0/search.pb.go | 681 +++++++++++++----- .../services/search/v0/search.swagger.json | 64 +- .../opencloud/services/search/v0/search.proto | 40 +- services/graph/pkg/service/v0/searchquery.go | 16 +- .../pkg/service/v0/searchquery_mapping.go | 19 + .../graph/pkg/service/v0/searchquery_test.go | 31 +- .../search/pkg/aggregation/aggregation.go | 10 +- services/search/pkg/aggregation/merge.go | 22 +- services/search/pkg/aggregation/merge_test.go | 76 +- services/search/pkg/aggregation/metric.go | 42 ++ .../search/pkg/aggregation/metric_test.go | 30 + services/search/pkg/aggregation/validate.go | 4 + .../search/pkg/aggregation/validate_test.go | 4 + services/search/pkg/bleve/aggregations.go | 16 +- .../pkg/opensearch/internal/aggs/aggs.go | 33 + .../pkg/opensearch/internal/aggs/aggs_test.go | 45 ++ services/search/pkg/parity/README.md | 3 + .../search/pkg/parity/aggregations_test.go | 46 +- 18 files changed, 954 insertions(+), 228 deletions(-) create mode 100644 services/search/pkg/aggregation/metric.go create mode 100644 services/search/pkg/aggregation/metric_test.go diff --git a/protogen/gen/opencloud/services/search/v0/search.pb.go b/protogen/gen/opencloud/services/search/v0/search.pb.go index a1146971f0..e147949dfc 100644 --- a/protogen/gen/opencloud/services/search/v0/search.pb.go +++ b/protogen/gen/opencloud/services/search/v0/search.pb.go @@ -25,6 +25,61 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type MetricKind int32 + +const ( + MetricKind_METRIC_KIND_UNSPECIFIED MetricKind = 0 + MetricKind_METRIC_KIND_SUM MetricKind = 1 + MetricKind_METRIC_KIND_MIN MetricKind = 2 + MetricKind_METRIC_KIND_MAX MetricKind = 3 + MetricKind_METRIC_KIND_AVG MetricKind = 4 +) + +// Enum value maps for MetricKind. +var ( + MetricKind_name = map[int32]string{ + 0: "METRIC_KIND_UNSPECIFIED", + 1: "METRIC_KIND_SUM", + 2: "METRIC_KIND_MIN", + 3: "METRIC_KIND_MAX", + 4: "METRIC_KIND_AVG", + } + MetricKind_value = map[string]int32{ + "METRIC_KIND_UNSPECIFIED": 0, + "METRIC_KIND_SUM": 1, + "METRIC_KIND_MIN": 2, + "METRIC_KIND_MAX": 3, + "METRIC_KIND_AVG": 4, + } +) + +func (x MetricKind) Enum() *MetricKind { + p := new(MetricKind) + *p = x + return p +} + +func (x MetricKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MetricKind) Descriptor() protoreflect.EnumDescriptor { + return file_opencloud_services_search_v0_search_proto_enumTypes[0].Descriptor() +} + +func (MetricKind) Type() protoreflect.EnumType { + return &file_opencloud_services_search_v0_search_proto_enumTypes[0] +} + +func (x MetricKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MetricKind.Descriptor instead. +func (MetricKind) EnumDescriptor() ([]byte, []int) { + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{0} +} + type BucketSortBy int32 const ( @@ -61,11 +116,11 @@ func (x BucketSortBy) String() string { } func (BucketSortBy) Descriptor() protoreflect.EnumDescriptor { - return file_opencloud_services_search_v0_search_proto_enumTypes[0].Descriptor() + return file_opencloud_services_search_v0_search_proto_enumTypes[1].Descriptor() } func (BucketSortBy) Type() protoreflect.EnumType { - return &file_opencloud_services_search_v0_search_proto_enumTypes[0] + return &file_opencloud_services_search_v0_search_proto_enumTypes[1] } func (x BucketSortBy) Number() protoreflect.EnumNumber { @@ -74,7 +129,7 @@ func (x BucketSortBy) Number() protoreflect.EnumNumber { // Deprecated: Use BucketSortBy.Descriptor instead. func (BucketSortBy) EnumDescriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{0} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{1} } type IndexSpaceResponse_Status int32 @@ -117,11 +172,11 @@ func (x IndexSpaceResponse_Status) String() string { } func (IndexSpaceResponse_Status) Descriptor() protoreflect.EnumDescriptor { - return file_opencloud_services_search_v0_search_proto_enumTypes[1].Descriptor() + return file_opencloud_services_search_v0_search_proto_enumTypes[2].Descriptor() } func (IndexSpaceResponse_Status) Type() protoreflect.EnumType { - return &file_opencloud_services_search_v0_search_proto_enumTypes[1] + return &file_opencloud_services_search_v0_search_proto_enumTypes[2] } func (x IndexSpaceResponse_Status) Number() protoreflect.EnumNumber { @@ -130,7 +185,7 @@ func (x IndexSpaceResponse_Status) Number() protoreflect.EnumNumber { // Deprecated: Use IndexSpaceResponse_Status.Descriptor instead. func (IndexSpaceResponse_Status) EnumDescriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{10, 0} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{12, 0} } type SearchRequest struct { @@ -465,14 +520,20 @@ type AggregationOption struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Required. The indexed field to aggregate on. + // Required. The indexed field to aggregate on (for terms/range + // aggregations) or to reduce (for metric aggregations). Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` // Optional. Maximum number of buckets to return for a terms aggregation, // applied after the cross-space merge. 0 returns every bucket. Ignored for - // range aggregations. + // range and metric aggregations. Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // Optional. Controls bucket selection, ordering and filtering. + // Ignored for metric aggregations. BucketDefinition *BucketDefinition `protobuf:"bytes,3,opt,name=bucket_definition,json=bucketDefinition,proto3" json:"bucket_definition,omitempty"` + // Optional. When set, this aggregation is a scalar metric over `field` + // rather than a bucket aggregation; the corresponding AggregationResult + // carries `metric` instead of `buckets`. + MetricDefinition *MetricDefinition `protobuf:"bytes,4,opt,name=metric_definition,json=metricDefinition,proto3" json:"metric_definition,omitempty"` } func (x *AggregationOption) Reset() { @@ -528,6 +589,60 @@ func (x *AggregationOption) GetBucketDefinition() *BucketDefinition { return nil } +func (x *AggregationOption) GetMetricDefinition() *MetricDefinition { + if x != nil { + return x.MetricDefinition + } + return nil +} + +type MetricDefinition struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Kind MetricKind `protobuf:"varint,1,opt,name=kind,proto3,enum=opencloud.services.search.v0.MetricKind" json:"kind,omitempty"` +} + +func (x *MetricDefinition) Reset() { + *x = MetricDefinition{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MetricDefinition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricDefinition) ProtoMessage() {} + +func (x *MetricDefinition) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetricDefinition.ProtoReflect.Descriptor instead. +func (*MetricDefinition) Descriptor() ([]byte, []int) { + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{5} +} + +func (x *MetricDefinition) GetKind() MetricKind { + if x != nil { + return x.Kind + } + return MetricKind_METRIC_KIND_UNSPECIFIED +} + type BucketDefinition struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -546,7 +661,7 @@ type BucketDefinition struct { func (x *BucketDefinition) Reset() { *x = BucketDefinition{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -559,7 +674,7 @@ func (x *BucketDefinition) String() string { func (*BucketDefinition) ProtoMessage() {} func (x *BucketDefinition) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -572,7 +687,7 @@ func (x *BucketDefinition) ProtoReflect() protoreflect.Message { // Deprecated: Use BucketDefinition.ProtoReflect.Descriptor instead. func (*BucketDefinition) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{5} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{6} } func (x *BucketDefinition) GetSortBy() BucketSortBy { @@ -618,7 +733,7 @@ type BucketRange struct { func (x *BucketRange) Reset() { *x = BucketRange{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -631,7 +746,7 @@ func (x *BucketRange) String() string { func (*BucketRange) ProtoMessage() {} func (x *BucketRange) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -644,7 +759,7 @@ func (x *BucketRange) ProtoReflect() protoreflect.Message { // Deprecated: Use BucketRange.ProtoReflect.Descriptor instead. func (*BucketRange) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{6} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{7} } func (x *BucketRange) GetFrom() string { @@ -669,12 +784,15 @@ type AggregationResult struct { Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` Buckets []*Bucket `protobuf:"bytes,2,rep,name=buckets,proto3" json:"buckets,omitempty"` + // Result of a metric aggregation (metric_definition set on the + // corresponding AggregationOption). Unset for terms/range aggregations. + Metric *Metric `protobuf:"bytes,3,opt,name=metric,proto3" json:"metric,omitempty"` } func (x *AggregationResult) Reset() { *x = AggregationResult{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -687,7 +805,7 @@ func (x *AggregationResult) String() string { func (*AggregationResult) ProtoMessage() {} func (x *AggregationResult) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -700,7 +818,7 @@ func (x *AggregationResult) ProtoReflect() protoreflect.Message { // Deprecated: Use AggregationResult.ProtoReflect.Descriptor instead. func (*AggregationResult) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{7} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{8} } func (x *AggregationResult) GetField() string { @@ -717,6 +835,106 @@ func (x *AggregationResult) GetBuckets() []*Bucket { return nil } +func (x *AggregationResult) GetMetric() *Metric { + if x != nil { + return x.Metric + } + return nil +} + +type Metric struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Echoes the kind of the corresponding MetricDefinition. + Kind MetricKind `protobuf:"varint,1,opt,name=kind,proto3,enum=opencloud.services.search.v0.MetricKind" json:"kind,omitempty"` + // The reduced value. Set by the service layer after the cross-space + // merge, unset when no match carries a value. + Value *float64 `protobuf:"fixed64,2,opt,name=value,proto3,oneof" json:"value,omitempty"` + // Accumulators over the values of all matches. An engine fills all of + // them, whatever the kind, so the service layer can merge results across + // spaces without knowing the kind (an average of averages is no average). + Sum float64 `protobuf:"fixed64,3,opt,name=sum,proto3" json:"sum,omitempty"` + Count int64 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` + Min float64 `protobuf:"fixed64,5,opt,name=min,proto3" json:"min,omitempty"` + Max float64 `protobuf:"fixed64,6,opt,name=max,proto3" json:"max,omitempty"` +} + +func (x *Metric) Reset() { + *x = Metric{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Metric) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Metric) ProtoMessage() {} + +func (x *Metric) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Metric.ProtoReflect.Descriptor instead. +func (*Metric) Descriptor() ([]byte, []int) { + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{9} +} + +func (x *Metric) GetKind() MetricKind { + if x != nil { + return x.Kind + } + return MetricKind_METRIC_KIND_UNSPECIFIED +} + +func (x *Metric) GetValue() float64 { + if x != nil && x.Value != nil { + return *x.Value + } + return 0 +} + +func (x *Metric) GetSum() float64 { + if x != nil { + return x.Sum + } + return 0 +} + +func (x *Metric) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *Metric) GetMin() float64 { + if x != nil { + return x.Min + } + return 0 +} + +func (x *Metric) GetMax() float64 { + if x != nil { + return x.Max + } + return 0 +} + type Bucket struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -729,7 +947,7 @@ type Bucket struct { func (x *Bucket) Reset() { *x = Bucket{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -742,7 +960,7 @@ func (x *Bucket) String() string { func (*Bucket) ProtoMessage() {} func (x *Bucket) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -755,7 +973,7 @@ func (x *Bucket) ProtoReflect() protoreflect.Message { // Deprecated: Use Bucket.ProtoReflect.Descriptor instead. func (*Bucket) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{8} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{10} } func (x *Bucket) GetKey() string { @@ -786,7 +1004,7 @@ type IndexSpaceRequest struct { func (x *IndexSpaceRequest) Reset() { *x = IndexSpaceRequest{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -799,7 +1017,7 @@ func (x *IndexSpaceRequest) String() string { func (*IndexSpaceRequest) ProtoMessage() {} func (x *IndexSpaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -812,7 +1030,7 @@ func (x *IndexSpaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use IndexSpaceRequest.ProtoReflect.Descriptor instead. func (*IndexSpaceRequest) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{9} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{11} } func (x *IndexSpaceRequest) GetSpaceId() string { @@ -865,7 +1083,7 @@ type IndexSpaceResponse struct { func (x *IndexSpaceResponse) Reset() { *x = IndexSpaceResponse{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -878,7 +1096,7 @@ func (x *IndexSpaceResponse) String() string { func (*IndexSpaceResponse) ProtoMessage() {} func (x *IndexSpaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_services_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_services_search_v0_search_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -891,7 +1109,7 @@ func (x *IndexSpaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use IndexSpaceResponse.ProtoReflect.Descriptor instead. func (*IndexSpaceResponse) Descriptor() ([]byte, []int) { - return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{10} + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{12} } func (x *IndexSpaceResponse) GetSpaceId() string { @@ -1026,7 +1244,7 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, - 0xa6, 0x01, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, + 0x89, 0x02, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x18, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, @@ -1036,130 +1254,165 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x10, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, - 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xe4, 0x01, 0x0a, 0x10, 0x42, 0x75, 0x63, - 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, - 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2a, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, - 0x63, 0x6b, 0x65, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74, - 0x42, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, - 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x69, 0x73, 0x44, 0x65, 0x73, - 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x23, 0x0a, 0x0d, 0x6d, 0x69, 0x6e, 0x69, 0x6d, - 0x75, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, - 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x41, 0x0a, 0x06, - 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, - 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, - 0x65, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x22, - 0x31, 0x0a, 0x0b, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x12, - 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x72, - 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x74, 0x6f, 0x22, 0x69, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x3e, 0x0a, - 0x07, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, - 0x63, 0x6b, 0x65, 0x74, 0x52, 0x07, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x22, 0x30, 0x0a, - 0x06, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, - 0x94, 0x01, 0x0a, 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, - 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, - 0x63, 0x65, 0x5f, 0x72, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0c, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x52, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x26, - 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, - 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x22, 0xfa, 0x02, 0x0a, 0x12, 0x49, 0x6e, 0x64, 0x65, 0x78, - 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, - 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x0e, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0d, 0x73, 0x70, 0x61, - 0x63, 0x65, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, - 0x64, 0x65, 0x78, 0x65, 0x64, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0d, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x53, 0x70, 0x61, 0x63, 0x65, - 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x53, 0x70, - 0x61, 0x63, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x4f, 0x0a, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x37, 0x2e, 0x6f, 0x70, 0x65, + 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x61, 0x0a, 0x11, 0x6d, 0x65, 0x74, 0x72, + 0x69, 0x63, 0x5f, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, + 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x10, 0x6d, 0x65, 0x74, 0x72, 0x69, + 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x50, 0x0a, 0x10, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x3c, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, + 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x22, 0xe4, 0x01, + 0x0a, 0x10, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x2a, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, + 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x52, + 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x64, 0x65, + 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, + 0x69, 0x73, 0x44, 0x65, 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x23, 0x0a, 0x0d, + 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x0c, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x12, 0x41, 0x0a, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x72, 0x61, + 0x6e, 0x67, 0x65, 0x73, 0x22, 0x31, 0x0a, 0x0b, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x61, + 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x74, 0x6f, 0x22, 0xa7, 0x01, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, + 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, + 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, + 0x65, 0x6c, 0x64, 0x12, 0x3e, 0x0a, 0x07, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x2e, 0x76, 0x30, 0x2e, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x07, 0x62, 0x75, 0x63, 0x6b, + 0x65, 0x74, 0x73, 0x12, 0x3c, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, + 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, + 0x63, 0x22, 0xb7, 0x01, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x3c, 0x0a, 0x04, + 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, - 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x56, 0x0a, 0x06, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, - 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x54, 0x41, - 0x54, 0x55, 0x53, 0x5f, 0x53, 0x55, 0x43, 0x43, 0x45, 0x53, 0x53, 0x10, 0x01, 0x12, 0x12, 0x0a, - 0x0e, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x53, 0x4b, 0x49, 0x50, 0x50, 0x45, 0x44, 0x10, - 0x02, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, - 0x52, 0x10, 0x03, 0x2a, 0x8c, 0x01, 0x0a, 0x0c, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x6f, - 0x72, 0x74, 0x42, 0x79, 0x12, 0x1e, 0x0a, 0x1a, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, - 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, - 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, - 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x43, 0x4f, 0x55, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x20, - 0x0a, 0x1c, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, - 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, 0x02, - 0x12, 0x20, 0x0a, 0x1c, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, - 0x42, 0x59, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x4e, 0x55, 0x4d, 0x42, 0x45, 0x52, - 0x10, 0x03, 0x32, 0xb3, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x50, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x85, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x12, 0x2b, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, - 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, + 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x88, 0x01, 0x01, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x01, 0x52, 0x03, 0x73, 0x75, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x10, 0x0a, + 0x03, 0x6d, 0x69, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x6d, 0x69, 0x6e, 0x12, + 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x78, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x6d, 0x61, + 0x78, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x30, 0x0a, 0x06, 0x42, + 0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x94, 0x01, + 0x0a, 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x17, + 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, 0x63, 0x65, + 0x5f, 0x72, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, + 0x66, 0x6f, 0x72, 0x63, 0x65, 0x52, 0x65, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0b, + 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, + 0x65, 0x6e, 0x63, 0x79, 0x22, 0xfa, 0x02, 0x0a, 0x12, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, + 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x0e, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, + 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0d, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x64, 0x65, + 0x78, 0x65, 0x64, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0d, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x53, 0x70, 0x61, 0x63, 0x65, 0x73, 0x12, + 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x53, 0x70, 0x61, 0x63, + 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x4f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x37, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, + 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x56, 0x0a, 0x06, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, + 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x54, 0x41, 0x54, 0x55, + 0x53, 0x5f, 0x53, 0x55, 0x43, 0x43, 0x45, 0x53, 0x53, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x53, 0x4b, 0x49, 0x50, 0x50, 0x45, 0x44, 0x10, 0x02, 0x12, + 0x10, 0x0a, 0x0c, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, + 0x03, 0x2a, 0x7d, 0x0a, 0x0a, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x12, + 0x1b, 0x0a, 0x17, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x55, + 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, + 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x53, 0x55, 0x4d, 0x10, + 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, + 0x5f, 0x4d, 0x49, 0x4e, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x45, 0x54, 0x52, 0x49, 0x43, + 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x4d, 0x41, 0x58, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4d, + 0x45, 0x54, 0x52, 0x49, 0x43, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x41, 0x56, 0x47, 0x10, 0x04, + 0x2a, 0x8c, 0x01, 0x0a, 0x0c, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x42, + 0x79, 0x12, 0x1e, 0x0a, 0x1a, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, + 0x5f, 0x42, 0x59, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, + 0x00, 0x12, 0x18, 0x0a, 0x14, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, + 0x5f, 0x42, 0x59, 0x5f, 0x43, 0x4f, 0x55, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x20, 0x0a, 0x1c, 0x42, + 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, 0x4b, 0x45, + 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x20, 0x0a, + 0x1c, 0x42, 0x55, 0x43, 0x4b, 0x45, 0x54, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x42, 0x59, 0x5f, + 0x4b, 0x45, 0x59, 0x5f, 0x41, 0x53, 0x5f, 0x4e, 0x55, 0x4d, 0x42, 0x45, 0x52, 0x10, 0x03, 0x32, + 0xb3, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x12, 0x85, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, 0x82, 0xd3, 0xe4, - 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x98, 0x01, - 0x0a, 0x0a, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x12, 0x2f, 0x2e, 0x6f, - 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, - 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, - 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x25, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1f, 0x3a, 0x01, 0x2a, 0x22, 0x1a, 0x2f, 0x61, 0x70, 0x69, - 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, - 0x2d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x30, 0x01, 0x32, 0xa7, 0x01, 0x0a, 0x0d, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x95, 0x01, 0x0a, 0x06, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, - 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x82, 0xd3, 0xe4, 0x93, - 0x02, 0x20, 0x3a, 0x01, 0x2a, 0x22, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x42, 0xf2, 0x02, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, - 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, - 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, - 0x30, 0x92, 0x41, 0xa2, 0x02, 0x12, 0xb7, 0x01, 0x0a, 0x10, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, - 0x6f, 0x75, 0x64, 0x20, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x51, 0x0a, 0x0e, 0x4f, 0x70, - 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, 0x62, 0x48, 0x12, 0x29, 0x68, 0x74, - 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x1a, 0x14, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, - 0x40, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2a, 0x49, 0x0a, - 0x0a, 0x41, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, 0x30, 0x12, 0x3b, 0x68, 0x74, 0x74, - 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, - 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x62, 0x6c, 0x6f, 0x62, 0x2f, 0x6d, 0x61, 0x69, 0x6e, - 0x2f, 0x4c, 0x49, 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x2a, - 0x02, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3e, 0x0a, 0x10, 0x44, 0x65, 0x76, 0x65, 0x6c, - 0x6f, 0x70, 0x65, 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, 0x2a, 0x68, 0x74, 0x74, - 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, + 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x98, 0x01, 0x0a, 0x0a, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x12, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, + 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, + 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, + 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, + 0xe4, 0x93, 0x02, 0x1f, 0x3a, 0x01, 0x2a, 0x22, 0x1a, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, + 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2d, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x30, 0x01, 0x32, 0xa7, 0x01, 0x0a, 0x0d, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x95, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x12, 0x30, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, + 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x20, 0x3a, + 0x01, 0x2a, 0x22, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x42, + 0xf2, 0x02, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, + 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, + 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x92, 0x41, + 0xa2, 0x02, 0x12, 0xb7, 0x01, 0x0a, 0x10, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x6c, 0x6f, 0x75, 0x64, + 0x20, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x51, 0x0a, 0x0e, 0x4f, 0x70, 0x65, 0x6e, 0x43, + 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, 0x62, 0x48, 0x12, 0x29, 0x68, 0x74, 0x74, 0x70, 0x73, + 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x1a, 0x14, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x40, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x65, 0x75, 0x2a, 0x49, 0x0a, 0x0a, 0x41, 0x70, + 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, 0x30, 0x12, 0x3b, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, + 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, + 0x6f, 0x75, 0x64, 0x2f, 0x62, 0x6c, 0x6f, 0x62, 0x2f, 0x6d, 0x61, 0x69, 0x6e, 0x2f, 0x4c, 0x49, + 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x2a, 0x02, 0x01, 0x02, + 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, + 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, + 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3e, 0x0a, 0x10, 0x44, 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, + 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, 0x2a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, + 0x2f, 0x2f, 0x64, 0x6f, 0x63, 0x73, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x65, 0x75, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1174,52 +1427,59 @@ func file_opencloud_services_search_v0_search_proto_rawDescGZIP() []byte { return file_opencloud_services_search_v0_search_proto_rawDescData } -var file_opencloud_services_search_v0_search_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_opencloud_services_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_opencloud_services_search_v0_search_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_opencloud_services_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_opencloud_services_search_v0_search_proto_goTypes = []interface{}{ - (BucketSortBy)(0), // 0: opencloud.services.search.v0.BucketSortBy - (IndexSpaceResponse_Status)(0), // 1: opencloud.services.search.v0.IndexSpaceResponse.Status - (*SearchRequest)(nil), // 2: opencloud.services.search.v0.SearchRequest - (*SearchResponse)(nil), // 3: opencloud.services.search.v0.SearchResponse - (*SearchIndexRequest)(nil), // 4: opencloud.services.search.v0.SearchIndexRequest - (*SearchIndexResponse)(nil), // 5: opencloud.services.search.v0.SearchIndexResponse - (*AggregationOption)(nil), // 6: opencloud.services.search.v0.AggregationOption - (*BucketDefinition)(nil), // 7: opencloud.services.search.v0.BucketDefinition - (*BucketRange)(nil), // 8: opencloud.services.search.v0.BucketRange - (*AggregationResult)(nil), // 9: opencloud.services.search.v0.AggregationResult - (*Bucket)(nil), // 10: opencloud.services.search.v0.Bucket - (*IndexSpaceRequest)(nil), // 11: opencloud.services.search.v0.IndexSpaceRequest - (*IndexSpaceResponse)(nil), // 12: opencloud.services.search.v0.IndexSpaceResponse - (*v0.Reference)(nil), // 13: opencloud.messages.search.v0.Reference - (*v0.Match)(nil), // 14: opencloud.messages.search.v0.Match - (*durationpb.Duration)(nil), // 15: google.protobuf.Duration + (MetricKind)(0), // 0: opencloud.services.search.v0.MetricKind + (BucketSortBy)(0), // 1: opencloud.services.search.v0.BucketSortBy + (IndexSpaceResponse_Status)(0), // 2: opencloud.services.search.v0.IndexSpaceResponse.Status + (*SearchRequest)(nil), // 3: opencloud.services.search.v0.SearchRequest + (*SearchResponse)(nil), // 4: opencloud.services.search.v0.SearchResponse + (*SearchIndexRequest)(nil), // 5: opencloud.services.search.v0.SearchIndexRequest + (*SearchIndexResponse)(nil), // 6: opencloud.services.search.v0.SearchIndexResponse + (*AggregationOption)(nil), // 7: opencloud.services.search.v0.AggregationOption + (*MetricDefinition)(nil), // 8: opencloud.services.search.v0.MetricDefinition + (*BucketDefinition)(nil), // 9: opencloud.services.search.v0.BucketDefinition + (*BucketRange)(nil), // 10: opencloud.services.search.v0.BucketRange + (*AggregationResult)(nil), // 11: opencloud.services.search.v0.AggregationResult + (*Metric)(nil), // 12: opencloud.services.search.v0.Metric + (*Bucket)(nil), // 13: opencloud.services.search.v0.Bucket + (*IndexSpaceRequest)(nil), // 14: opencloud.services.search.v0.IndexSpaceRequest + (*IndexSpaceResponse)(nil), // 15: opencloud.services.search.v0.IndexSpaceResponse + (*v0.Reference)(nil), // 16: opencloud.messages.search.v0.Reference + (*v0.Match)(nil), // 17: opencloud.messages.search.v0.Match + (*durationpb.Duration)(nil), // 18: google.protobuf.Duration } var file_opencloud_services_search_v0_search_proto_depIdxs = []int32{ - 13, // 0: opencloud.services.search.v0.SearchRequest.ref:type_name -> opencloud.messages.search.v0.Reference - 6, // 1: opencloud.services.search.v0.SearchRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 14, // 2: opencloud.services.search.v0.SearchResponse.matches:type_name -> opencloud.messages.search.v0.Match - 9, // 3: opencloud.services.search.v0.SearchResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 13, // 4: opencloud.services.search.v0.SearchIndexRequest.ref:type_name -> opencloud.messages.search.v0.Reference - 6, // 5: opencloud.services.search.v0.SearchIndexRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 14, // 6: opencloud.services.search.v0.SearchIndexResponse.matches:type_name -> opencloud.messages.search.v0.Match - 9, // 7: opencloud.services.search.v0.SearchIndexResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 7, // 8: opencloud.services.search.v0.AggregationOption.bucket_definition:type_name -> opencloud.services.search.v0.BucketDefinition - 0, // 9: opencloud.services.search.v0.BucketDefinition.sort_by:type_name -> opencloud.services.search.v0.BucketSortBy - 8, // 10: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange - 10, // 11: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket - 15, // 12: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration - 1, // 13: opencloud.services.search.v0.IndexSpaceResponse.status:type_name -> opencloud.services.search.v0.IndexSpaceResponse.Status - 2, // 14: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest - 11, // 15: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest - 4, // 16: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest - 3, // 17: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse - 12, // 18: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse - 5, // 19: opencloud.services.search.v0.IndexProvider.Search:output_type -> opencloud.services.search.v0.SearchIndexResponse - 17, // [17:20] is the sub-list for method output_type - 14, // [14:17] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 16, // 0: opencloud.services.search.v0.SearchRequest.ref:type_name -> opencloud.messages.search.v0.Reference + 7, // 1: opencloud.services.search.v0.SearchRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption + 17, // 2: opencloud.services.search.v0.SearchResponse.matches:type_name -> opencloud.messages.search.v0.Match + 11, // 3: opencloud.services.search.v0.SearchResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 16, // 4: opencloud.services.search.v0.SearchIndexRequest.ref:type_name -> opencloud.messages.search.v0.Reference + 7, // 5: opencloud.services.search.v0.SearchIndexRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption + 17, // 6: opencloud.services.search.v0.SearchIndexResponse.matches:type_name -> opencloud.messages.search.v0.Match + 11, // 7: opencloud.services.search.v0.SearchIndexResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 9, // 8: opencloud.services.search.v0.AggregationOption.bucket_definition:type_name -> opencloud.services.search.v0.BucketDefinition + 8, // 9: opencloud.services.search.v0.AggregationOption.metric_definition:type_name -> opencloud.services.search.v0.MetricDefinition + 0, // 10: opencloud.services.search.v0.MetricDefinition.kind:type_name -> opencloud.services.search.v0.MetricKind + 1, // 11: opencloud.services.search.v0.BucketDefinition.sort_by:type_name -> opencloud.services.search.v0.BucketSortBy + 10, // 12: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange + 13, // 13: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket + 12, // 14: opencloud.services.search.v0.AggregationResult.metric:type_name -> opencloud.services.search.v0.Metric + 0, // 15: opencloud.services.search.v0.Metric.kind:type_name -> opencloud.services.search.v0.MetricKind + 18, // 16: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration + 2, // 17: opencloud.services.search.v0.IndexSpaceResponse.status:type_name -> opencloud.services.search.v0.IndexSpaceResponse.Status + 3, // 18: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest + 14, // 19: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest + 5, // 20: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest + 4, // 21: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse + 15, // 22: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse + 6, // 23: opencloud.services.search.v0.IndexProvider.Search:output_type -> opencloud.services.search.v0.SearchIndexResponse + 21, // [21:24] is the sub-list for method output_type + 18, // [18:21] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_opencloud_services_search_v0_search_proto_init() } @@ -1289,7 +1549,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BucketDefinition); i { + switch v := v.(*MetricDefinition); i { case 0: return &v.state case 1: @@ -1301,7 +1561,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BucketRange); i { + switch v := v.(*BucketDefinition); i { case 0: return &v.state case 1: @@ -1313,7 +1573,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AggregationResult); i { + switch v := v.(*BucketRange); i { case 0: return &v.state case 1: @@ -1325,7 +1585,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Bucket); i { + switch v := v.(*AggregationResult); i { case 0: return &v.state case 1: @@ -1337,7 +1597,7 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*IndexSpaceRequest); i { + switch v := v.(*Metric); i { case 0: return &v.state case 1: @@ -1349,6 +1609,30 @@ func file_opencloud_services_search_v0_search_proto_init() { } } file_opencloud_services_search_v0_search_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Bucket); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_search_v0_search_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IndexSpaceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_services_search_v0_search_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*IndexSpaceResponse); i { case 0: return &v.state @@ -1363,13 +1647,14 @@ func file_opencloud_services_search_v0_search_proto_init() { } file_opencloud_services_search_v0_search_proto_msgTypes[0].OneofWrappers = []interface{}{} file_opencloud_services_search_v0_search_proto_msgTypes[2].OneofWrappers = []interface{}{} + file_opencloud_services_search_v0_search_proto_msgTypes[9].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_opencloud_services_search_v0_search_proto_rawDesc, - NumEnums: 2, - NumMessages: 11, + NumEnums: 3, + NumMessages: 13, NumExtensions: 0, NumServices: 2, }, diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index 67af13671d..c518ac6fa3 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -172,16 +172,20 @@ "properties": { "field": { "type": "string", - "description": "Required. The indexed field to aggregate on." + "description": "Required. The indexed field to aggregate on (for terms/range\naggregations) or to reduce (for metric aggregations)." }, "size": { "type": "integer", "format": "int32", - "description": "Optional. Maximum number of buckets to return for a terms aggregation,\napplied after the cross-space merge. 0 returns every bucket. Ignored for\nrange aggregations." + "description": "Optional. Maximum number of buckets to return for a terms aggregation,\napplied after the cross-space merge. 0 returns every bucket. Ignored for\nrange and metric aggregations." }, "bucketDefinition": { "$ref": "#/definitions/v0BucketDefinition", - "description": "Optional. Controls bucket selection, ordering and filtering." + "description": "Optional. Controls bucket selection, ordering and filtering.\nIgnored for metric aggregations." + }, + "metricDefinition": { + "$ref": "#/definitions/v0MetricDefinition", + "description": "Optional. When set, this aggregation is a scalar metric over `field`\nrather than a bucket aggregation; the corresponding AggregationResult\ncarries `metric` instead of `buckets`." } } }, @@ -196,6 +200,10 @@ "items": { "$ref": "#/definitions/v0Bucket" } + }, + "metric": { + "$ref": "#/definitions/v0Metric", + "description": "Result of a metric aggregation (metric_definition set on the\ncorresponding AggregationOption). Unset for terms/range aggregations." } }, "description": "One result per AggregationOption, at the same position." @@ -535,6 +543,56 @@ } } }, + "v0Metric": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/v0MetricKind", + "description": "Echoes the kind of the corresponding MetricDefinition." + }, + "value": { + "type": "number", + "format": "double", + "description": "The reduced value. Set by the service layer after the cross-space\nmerge, unset when no match carries a value." + }, + "sum": { + "type": "number", + "format": "double", + "description": "Accumulators over the values of all matches. An engine fills all of\nthem, whatever the kind, so the service layer can merge results across\nspaces without knowing the kind (an average of averages is no average)." + }, + "count": { + "type": "string", + "format": "int64" + }, + "min": { + "type": "number", + "format": "double" + }, + "max": { + "type": "number", + "format": "double" + } + } + }, + "v0MetricDefinition": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/v0MetricKind" + } + } + }, + "v0MetricKind": { + "type": "string", + "enum": [ + "METRIC_KIND_UNSPECIFIED", + "METRIC_KIND_SUM", + "METRIC_KIND_MIN", + "METRIC_KIND_MAX", + "METRIC_KIND_AVG" + ], + "default": "METRIC_KIND_UNSPECIFIED" + }, "v0MotionPhoto": { "type": "object", "properties": { diff --git a/protogen/proto/opencloud/services/search/v0/search.proto b/protogen/proto/opencloud/services/search/v0/search.proto index 18657ed774..1484979574 100644 --- a/protogen/proto/opencloud/services/search/v0/search.proto +++ b/protogen/proto/opencloud/services/search/v0/search.proto @@ -124,14 +124,32 @@ message SearchIndexResponse { } message AggregationOption { - // Required. The indexed field to aggregate on. + // Required. The indexed field to aggregate on (for terms/range + // aggregations) or to reduce (for metric aggregations). string field = 1; // Optional. Maximum number of buckets to return for a terms aggregation, // applied after the cross-space merge. 0 returns every bucket. Ignored for - // range aggregations. + // range and metric aggregations. int32 size = 2 [(google.api.field_behavior) = OPTIONAL]; // Optional. Controls bucket selection, ordering and filtering. + // Ignored for metric aggregations. BucketDefinition bucket_definition = 3 [(google.api.field_behavior) = OPTIONAL]; + // Optional. When set, this aggregation is a scalar metric over `field` + // rather than a bucket aggregation; the corresponding AggregationResult + // carries `metric` instead of `buckets`. + MetricDefinition metric_definition = 4 [(google.api.field_behavior) = OPTIONAL]; +} + +message MetricDefinition { + MetricKind kind = 1; +} + +enum MetricKind { + METRIC_KIND_UNSPECIFIED = 0; + METRIC_KIND_SUM = 1; + METRIC_KIND_MIN = 2; + METRIC_KIND_MAX = 3; + METRIC_KIND_AVG = 4; } message BucketDefinition { @@ -164,6 +182,24 @@ message BucketRange { message AggregationResult { string field = 1; repeated Bucket buckets = 2; + // Result of a metric aggregation (metric_definition set on the + // corresponding AggregationOption). Unset for terms/range aggregations. + Metric metric = 3; +} + +message Metric { + // Echoes the kind of the corresponding MetricDefinition. + MetricKind kind = 1; + // The reduced value. Set by the service layer after the cross-space + // merge, unset when no match carries a value. + optional double value = 2; + // Accumulators over the values of all matches. An engine fills all of + // them, whatever the kind, so the service layer can merge results across + // spaces without knowing the kind (an average of averages is no average). + double sum = 3; + int64 count = 4; + double min = 5; + double max = 6; } message Bucket { diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go index f9b8fae479..be86393775 100644 --- a/services/graph/pkg/service/v0/searchquery.go +++ b/services/graph/pkg/service/v0/searchquery.go @@ -98,8 +98,6 @@ func unsupportedProperty(sr libregraph.SearchRequest) string { return "@libre.graph.geohashDefinition" case len(a.LibreGraphSubAggregations) > 0: return "@libre.graph.subAggregations" - case a.LibreGraphMetricDefinition != nil: - return "@libre.graph.metricDefinition" } } return "" @@ -218,17 +216,25 @@ func validatePagination(fromP, sizeP *int32) error { return search.CheckResultWindow(from, size) } -// openapi-generator enforces neither the enums nor the bounds of the spec. -// Whether an aggregation fits its field is for aggregation.ValidateOptions to -// say. +// openapi-generator enforces neither the enums and bounds of the spec nor its +// at-most-one definition rule. Whether an aggregation fits its field is for +// aggregation.ValidateOptions to say. func validateAggregations(aggs []libregraph.AggregationOption) error { for _, a := range aggs { if a.Field == "" { return fmt.Errorf("aggregation field must not be empty") } + if a.BucketDefinition != nil && a.LibreGraphMetricDefinition != nil { + return fmt.Errorf("aggregation on %q sets more than one of bucketDefinition and @libre.graph.metricDefinition", a.Field) + } if a.Size != nil && *a.Size < 1 { return fmt.Errorf("size of the aggregation on %q must be at least 1", a.Field) } + if md := a.LibreGraphMetricDefinition; md != nil { + if _, ok := metricKinds[md.Kind]; !ok { + return fmt.Errorf("unsupported metric kind %q on field %q", md.Kind, a.Field) + } + } if bd := a.BucketDefinition; bd != nil { if _, ok := bucketSortBy[bd.SortBy]; !ok { return fmt.Errorf("unsupported sortBy %q on field %q", bd.SortBy, a.Field) diff --git a/services/graph/pkg/service/v0/searchquery_mapping.go b/services/graph/pkg/service/v0/searchquery_mapping.go index f96636ca9b..1a944ec830 100644 --- a/services/graph/pkg/service/v0/searchquery_mapping.go +++ b/services/graph/pkg/service/v0/searchquery_mapping.go @@ -21,6 +21,13 @@ var bucketSortBy = map[string]searchsvc.BucketSortBy{ "keyAsNumber": searchsvc.BucketSortBy_BUCKET_SORT_BY_KEY_AS_NUMBER, } +var metricKinds = map[string]searchsvc.MetricKind{ + "sum": searchsvc.MetricKind_METRIC_KIND_SUM, + "min": searchsvc.MetricKind_METRIC_KIND_MIN, + "max": searchsvc.MetricKind_METRIC_KIND_MAX, + "avg": searchsvc.MetricKind_METRIC_KIND_AVG, +} + // driveItemFields are the scalar driveItem properties a hit carries and the // index fields behind them; a facet property (audio.artist) is its index // field already. @@ -61,6 +68,9 @@ func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*search if a.BucketDefinition != nil { agg.BucketDefinition = libregraphBucketDefinitionToSearch(*a.BucketDefinition) } + if md := a.LibreGraphMetricDefinition; md != nil { + agg.MetricDefinition = &searchsvc.MetricDefinition{Kind: metricKinds[md.Kind]} + } out = append(out, agg) } return out @@ -101,6 +111,15 @@ func searchAggregationsToLibregraph(in []*searchsvc.AggregationResult, defs []li break } agg := libregraph.SearchAggregation{Field: libregraph.PtrString(def.Field)} + if md := def.LibreGraphMetricDefinition; md != nil { + agg.LibreGraphMetric = &libregraph.SearchMetric{Kind: libregraph.PtrString(md.Kind)} + // a metric without a single value has no value + if m := in[i].GetMetric(); m != nil { + agg.LibreGraphMetric.Value = m.Value + } + out = append(out, agg) + continue + } agg.Buckets = make([]libregraph.SearchBucket, 0, len(in[i].GetBuckets())) for _, b := range in[i].GetBuckets() { agg.Buckets = append(agg.Buckets, libregraph.SearchBucket{ diff --git a/services/graph/pkg/service/v0/searchquery_test.go b/services/graph/pkg/service/v0/searchquery_test.go index f620d2837d..0ca52122cc 100644 --- a/services/graph/pkg/service/v0/searchquery_test.go +++ b/services/graph/pkg/service/v0/searchquery_test.go @@ -86,7 +86,11 @@ func oneMatch(entity *searchmsg.Entity) *searchsvc.SearchResponse { } type searchAggregationJSON struct { - Field string `json:"field"` + Field string `json:"field"` + Metric *struct { + Kind string `json:"kind"` + Value *float64 `json:"value"` + } `json:"@libre.graph.metric"` Buckets []struct { Key string `json:"key"` Count int64 `json:"count"` @@ -299,6 +303,9 @@ var _ = ginkgo.Describe("SearchQuery", func() { Expect(rr.Body.String()).To(ContainSubstring("invalidRequest")) }, ginkgo.Entry("an empty field", `"aggregations": [{"field": ""}]`), + ginkgo.Entry("a bucket and a metric definition at once", + `"aggregations": [{"field": "audio.year", "bucketDefinition": {"sortBy": "count", "ranges": [{"from": "1980"}]}, "@libre.graph.metricDefinition": {"kind": "sum"}}]`), + ginkgo.Entry("an unknown metric kind", `"aggregations": [{"field": "audio.year", "@libre.graph.metricDefinition": {"kind": "median"}}]`), ginkgo.Entry("an unknown sortBy", `"aggregations": [{"field": "audio.artist", "bucketDefinition": {"sortBy": "relevance"}}]`), ginkgo.Entry("a size below one", `"aggregations": [{"field": "audio.artist", "size": 0}]`), ginkgo.Entry("a negative minimumCount", `"aggregations": [{"field": "audio.artist", "bucketDefinition": {"sortBy": "count", "minimumCount": -1}}]`), @@ -368,6 +375,27 @@ var _ = ginkgo.Describe("SearchQuery", func() { Expect(hitsContainer(rr).Aggregations[0].Field).To(Equal("lastModifiedDateTime")) }) + ginkgo.It("maps a metric result, without a value when the metric has none", func() { + value := 1986.5 + g, captured := graphWithAggregations( + &searchsvc.AggregationResult{Field: "audio.year", Metric: &searchsvc.Metric{Kind: searchsvc.MetricKind_METRIC_KIND_AVG, Value: &value, Sum: 3973, Count: 2}}, + &searchsvc.AggregationResult{Field: "audio.year", Metric: &searchsvc.Metric{Kind: searchsvc.MetricKind_METRIC_KIND_MIN}}, + ) + rr := postSearchQuery(g, searchQueryBody(`"aggregations": [ + {"field": "audio.year", "@libre.graph.metricDefinition": {"kind": "avg"}}, + {"field": "audio.year", "@libre.graph.metricDefinition": {"kind": "min"}} + ]`)) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + Expect(captured().GetAggregations()[0].GetMetricDefinition().GetKind()).To(Equal(searchsvc.MetricKind_METRIC_KIND_AVG)) + + aggs := hitsContainer(rr).Aggregations + Expect(aggs).To(HaveLen(2)) + Expect(aggs[0].Metric.Kind).To(Equal("avg")) + Expect(aggs[0].Metric.Value).To(HaveValue(Equal(1986.5))) + Expect(aggs[1].Metric.Kind).To(Equal("min")) + Expect(aggs[1].Metric.Value).To(BeNil(), "no value, not zero") + }) + ginkgo.DescribeTable("answers a property it does not evaluate with 501 instead of ignoring it", func(fragment, property string) { rr := postSearchQuery(graphWithoutSearch(), searchQueryBody(fragment)) @@ -379,7 +407,6 @@ var _ = ginkgo.Describe("SearchQuery", func() { ginkgo.Entry("aggregationFilters", `"aggregationFilters": ["audio.artist:\"ǂǂ5361786f6e\""]`, "aggregationFilters"), ginkgo.Entry("a geohash aggregation", `"aggregations": [{"field": "location", "@libre.graph.geohashDefinition": {"precision": 5}}]`, "geohashDefinition"), - ginkgo.Entry("a metric", `"aggregations": [{"field": "audio.year", "@libre.graph.metricDefinition": {"kind": "sum"}}]`, "metricDefinition"), ginkgo.Entry("sub-aggregations", `"aggregations": [{"field": "audio.artist", "@libre.graph.subAggregations": [{"field": "audio.album"}]}]`, "subAggregations"), ) diff --git a/services/search/pkg/aggregation/aggregation.go b/services/search/pkg/aggregation/aggregation.go index f5d6d16c87..e1dd3d4936 100644 --- a/services/search/pkg/aggregation/aggregation.go +++ b/services/search/pkg/aggregation/aggregation.go @@ -1,6 +1,6 @@ // Package aggregation holds what the engines and the service layer share -// about aggregations: the kind of an option, range bounds and the cross-space -// merge. +// about aggregations: the kind of an option, range bounds, metric +// accumulators and the cross-space merge. package aggregation import ( @@ -17,11 +17,15 @@ type Kind int const ( KindTerms Kind = iota KindRange + KindMetric ) // KindOf tells the kind from the definitions an option carries. func KindOf(opt *searchsvc.AggregationOption) Kind { - if len(opt.GetBucketDefinition().GetRanges()) > 0 { + switch { + case opt.GetMetricDefinition() != nil: + return KindMetric + case len(opt.GetBucketDefinition().GetRanges()) > 0: return KindRange } return KindTerms diff --git a/services/search/pkg/aggregation/merge.go b/services/search/pkg/aggregation/merge.go index 511cc664b6..ecbd584f93 100644 --- a/services/search/pkg/aggregation/merge.go +++ b/services/search/pkg/aggregation/merge.go @@ -18,7 +18,10 @@ func Empty(opts []*searchsvc.AggregationOption) []*searchsvc.AggregationResult { out := make([]*searchsvc.AggregationResult, len(opts)) for i, opt := range opts { out[i] = &searchsvc.AggregationResult{Field: opt.GetField()} - if KindOf(opt) == KindRange { + switch KindOf(opt) { + case KindMetric: + out[i].Metric = &searchsvc.Metric{Kind: opt.GetMetricDefinition().GetKind()} + case KindRange: out[i].Buckets = RangeBuckets(opt, nil) } } @@ -47,10 +50,14 @@ func Merge(opts []*searchsvc.AggregationOption, acc, results []*searchsvc.Aggreg if acc == nil { acc = Empty(opts) } - for i := range opts { + for i, opt := range opts { if i >= len(results) || results[i] == nil { continue } + if KindOf(opt) == KindMetric { + add(acc[i].GetMetric(), results[i].GetMetric()) + continue + } byKey := make(map[string]*searchsvc.Bucket, len(acc[i].GetBuckets())) for _, b := range acc[i].GetBuckets() { byKey[b.GetKey()] = b @@ -69,10 +76,17 @@ func Merge(opts []*searchsvc.AggregationOption, acc, results []*searchsvc.Aggreg } // Finalize shapes merged results per their options: buckets get their minimum -// count, order and size. +// count, order and size, metrics their value. func Finalize(opts []*searchsvc.AggregationOption, results []*searchsvc.AggregationResult) { for i, opt := range opts { - results[i].Buckets = shapeBuckets(opt, results[i].GetBuckets()) + r := results[i] + if KindOf(opt) == KindMetric { + if v, ok := MetricValue(r.GetMetric()); ok { + r.Metric.Value = &v + } + continue + } + r.Buckets = shapeBuckets(opt, r.GetBuckets()) } } diff --git a/services/search/pkg/aggregation/merge_test.go b/services/search/pkg/aggregation/merge_test.go index 7a63e644ff..35c4b2fe99 100644 --- a/services/search/pkg/aggregation/merge_test.go +++ b/services/search/pkg/aggregation/merge_test.go @@ -2,6 +2,7 @@ package aggregation_test import ( "fmt" + "slices" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -10,6 +11,22 @@ import ( "github.com/opencloud-eu/opencloud/services/search/pkg/aggregation" ) +func metricOpt(field string, kind searchsvc.MetricKind) *searchsvc.AggregationOption { + return &searchsvc.AggregationOption{Field: field, MetricDefinition: &searchsvc.MetricDefinition{Kind: kind}} +} + +// metric is what an engine answers for the given values: all accumulators. +func metric(field string, kind searchsvc.MetricKind, values ...float64) *searchsvc.AggregationResult { + m := &searchsvc.Metric{Kind: kind, Count: int64(len(values))} + if len(values) > 0 { + m.Min, m.Max = slices.Min(values), slices.Max(values) + } + for _, v := range values { + m.Sum += v + } + return &searchsvc.AggregationResult{Field: field, Metric: m} +} + func buckets(field string, bs ...*searchsvc.Bucket) *searchsvc.AggregationResult { return &searchsvc.AggregationResult{Field: field, Buckets: bs} } @@ -22,10 +39,19 @@ func rangesOf(sortBy searchsvc.BucketSortBy, rs ...*searchsvc.BucketRange) *sear return &searchsvc.BucketDefinition{SortBy: sortBy, Ranges: rs} } -// render flattens results like the parity suite: one line per bucket. +// render flattens results like the parity suite: one line per bucket or +// metric. func render(results []*searchsvc.AggregationResult) []string { out := []string{} for _, r := range results { + if m := r.GetMetric(); m != nil { + if m.Value == nil { + out = append(out, fmt.Sprintf("%s %s none", r.GetField(), m.GetKind())) + } else { + out = append(out, fmt.Sprintf("%s %s=%v", r.GetField(), m.GetKind(), m.GetValue())) + } + continue + } for _, b := range r.GetBuckets() { out = append(out, fmt.Sprintf("%s %s=%d", r.GetField(), b.GetKey(), b.GetCount())) } @@ -44,16 +70,27 @@ func merged(opts []*searchsvc.AggregationOption, spaces ...[]*searchsvc.Aggregat } var _ = Describe("Merge and Finalize", func() { + const ( + sum = searchsvc.MetricKind_METRIC_KIND_SUM + min = searchsvc.MetricKind_METRIC_KIND_MIN + max = searchsvc.MetricKind_METRIC_KIND_MAX + avg = searchsvc.MetricKind_METRIC_KIND_AVG + ) + It("answers one result per option without any space, every range at zero", func() { opts := []*searchsvc.AggregationOption{ {Field: "audio.artist"}, + metricOpt("audio.year", sum), {Field: "audio.year", BucketDefinition: rangesOf(searchsvc.BucketSortBy_BUCKET_SORT_BY_KEY_AS_NUMBER, &searchsvc.BucketRange{To: "1980"}, &searchsvc.BucketRange{From: "1980"})}, } got := merged(opts) - Expect(got).To(HaveLen(2)) + Expect(got).To(HaveLen(3)) Expect(got[0].GetField()).To(Equal("audio.artist")) Expect(got[0].GetBuckets()).To(BeEmpty()) - Expect(render(got)).To(Equal([]string{"audio.year ..1980=0", "audio.year 1980..=0"})) + Expect(render(got)).To(Equal([]string{ + "audio.year METRIC_KIND_SUM none", + "audio.year ..1980=0", "audio.year 1980..=0", + })) }) It("adds up the counts of a bucket across spaces", func() { @@ -66,10 +103,11 @@ var _ = Describe("Merge and Finalize", func() { }) It("leaves the answers of the spaces untouched", func() { - opts := []*searchsvc.AggregationOption{{Field: "audio.artist"}} - space := []*searchsvc.AggregationResult{buckets("audio.artist", bucket("Saxon", 2))} + opts := []*searchsvc.AggregationOption{{Field: "audio.artist"}, metricOpt("audio.year", sum)} + space := []*searchsvc.AggregationResult{buckets("audio.artist", bucket("Saxon", 2)), metric("audio.year", sum, 1980)} merged(opts, space, space) Expect(space[0].GetBuckets()[0].GetCount()).To(Equal(int64(2))) + Expect(space[1].GetMetric().GetSum()).To(Equal(1980.0)) }) It("keeps two terms aggregations on one field apart", func() { @@ -84,24 +122,46 @@ var _ = Describe("Merge and Finalize", func() { })) }) - It("keeps two range aggregations on one field apart", func() { + It("keeps range and metric aggregations on one field apart", func() { opts := []*searchsvc.AggregationOption{ {Field: "audio.year", BucketDefinition: rangesOf(searchsvc.BucketSortBy_BUCKET_SORT_BY_KEY_AS_NUMBER, &searchsvc.BucketRange{To: "1980"}, &searchsvc.BucketRange{From: "1980"})}, + metricOpt("audio.year", min), {Field: "audio.year", BucketDefinition: rangesOf(searchsvc.BucketSortBy_BUCKET_SORT_BY_KEY_AS_NUMBER, &searchsvc.BucketRange{To: "2000"}, &searchsvc.BucketRange{From: "2000"})}, + metricOpt("audio.year", max), } - space := func(below1980, from1980, below2000, from2000 int64) []*searchsvc.AggregationResult { + space := func(below1980, from1980, below2000, from2000 int64, years ...float64) []*searchsvc.AggregationResult { return []*searchsvc.AggregationResult{ buckets("audio.year", bucket("..1980", below1980), bucket("1980..", from1980)), + metric("audio.year", min, years...), buckets("audio.year", bucket("..2000", below2000), bucket("2000..", from2000)), + metric("audio.year", max, years...), } } - got := merged(opts, space(1, 1, 2, 0), space(0, 2, 1, 1)) + got := merged(opts, space(1, 1, 2, 0, 1971, 1982), space(0, 2, 1, 1, 1999, 2001)) Expect(render(got)).To(Equal([]string{ "audio.year ..1980=1", "audio.year 1980..=3", + "audio.year METRIC_KIND_MIN=1971", "audio.year ..2000=3", "audio.year 2000..=1", + "audio.year METRIC_KIND_MAX=2001", })) }) + DescribeTable("reduces a metric over the values of all spaces", + func(kind searchsvc.MetricKind, want string) { + opts := []*searchsvc.AggregationOption{metricOpt("audio.year", kind)} + got := merged(opts, + []*searchsvc.AggregationResult{metric("audio.year", kind, 1970, 1980, 1990)}, + []*searchsvc.AggregationResult{metric("audio.year", kind)}, + []*searchsvc.AggregationResult{metric("audio.year", kind, 2010)}, + ) + Expect(render(got)).To(Equal([]string{"audio.year " + want})) + }, + Entry("sum", sum, "METRIC_KIND_SUM=7950"), + Entry("min, a space without values is no zero", min, "METRIC_KIND_MIN=1970"), + Entry("max", max, "METRIC_KIND_MAX=2010"), + Entry("avg of the values, not of the averages of the spaces", avg, "METRIC_KIND_AVG=1987.5"), + ) + Describe("bucket order", func() { space := []*searchsvc.AggregationResult{buckets("audio.track", bucket("10", 2), bucket("9", 2), bucket("2", 5), bucket("b-side", 1), diff --git a/services/search/pkg/aggregation/metric.go b/services/search/pkg/aggregation/metric.go new file mode 100644 index 0000000000..9905198ee6 --- /dev/null +++ b/services/search/pkg/aggregation/metric.go @@ -0,0 +1,42 @@ +package aggregation + +import ( + searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" +) + +// Observe adds a field value to the accumulators of m. +func Observe(m *searchsvc.Metric, v float64) { + add(m, &searchsvc.Metric{Sum: v, Count: 1, Min: v, Max: v}) +} + +func add(into, from *searchsvc.Metric) { + if from.GetCount() == 0 { + return + } + if into.GetCount() == 0 { + into.Min, into.Max = from.GetMin(), from.GetMax() + } else { + into.Min, into.Max = min(into.GetMin(), from.GetMin()), max(into.GetMax(), from.GetMax()) + } + into.Sum += from.GetSum() + into.Count += from.GetCount() +} + +// MetricValue reduces the accumulators to the value of the metric's kind; +// there is none without a single observed value. +func MetricValue(m *searchsvc.Metric) (float64, bool) { + if m.GetCount() == 0 { + return 0, false + } + switch m.GetKind() { + case searchsvc.MetricKind_METRIC_KIND_SUM: + return m.GetSum(), true + case searchsvc.MetricKind_METRIC_KIND_MIN: + return m.GetMin(), true + case searchsvc.MetricKind_METRIC_KIND_MAX: + return m.GetMax(), true + case searchsvc.MetricKind_METRIC_KIND_AVG: + return m.GetSum() / float64(m.GetCount()), true + } + return 0, false +} diff --git a/services/search/pkg/aggregation/metric_test.go b/services/search/pkg/aggregation/metric_test.go new file mode 100644 index 0000000000..5a0a28dd0a --- /dev/null +++ b/services/search/pkg/aggregation/metric_test.go @@ -0,0 +1,30 @@ +package aggregation_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" + "github.com/opencloud-eu/opencloud/services/search/pkg/aggregation" +) + +var _ = Describe("Observe", func() { + It("keeps every accumulator, whatever the kind", func() { + m := &searchsvc.Metric{Kind: searchsvc.MetricKind_METRIC_KIND_MAX} + for _, v := range []float64{1982, 1971, 2001} { + aggregation.Observe(m, v) + } + Expect(m.GetCount()).To(Equal(int64(3))) + Expect(m.GetSum()).To(Equal(5954.0)) + Expect(m.GetMin()).To(Equal(1971.0)) + Expect(m.GetMax()).To(Equal(2001.0)) + Expect(m.Value).To(BeNil()) + }) + + It("takes the first value as minimum and maximum, a negative one too", func() { + m := &searchsvc.Metric{} + aggregation.Observe(m, -3) + Expect(m.GetMin()).To(Equal(-3.0)) + Expect(m.GetMax()).To(Equal(-3.0)) + }) +}) diff --git a/services/search/pkg/aggregation/validate.go b/services/search/pkg/aggregation/validate.go index 93fc50c3d0..d79e90505a 100644 --- a/services/search/pkg/aggregation/validate.go +++ b/services/search/pkg/aggregation/validate.go @@ -17,6 +17,10 @@ func ValidateOptions(opts []*searchsvc.AggregationOption, fieldType func(string) return fmt.Errorf("unknown aggregation field %q", field) } switch KindOf(opt) { + case KindMetric: + if t != mapping.TypeNumeric { + return fmt.Errorf("metric aggregation needs a numeric field, %q is none", field) + } case KindRange: parsed, err := ParseRanges(field, opt.GetBucketDefinition().GetRanges()) if err != nil { diff --git a/services/search/pkg/aggregation/validate_test.go b/services/search/pkg/aggregation/validate_test.go index a22fad545f..0fd28c59c3 100644 --- a/services/search/pkg/aggregation/validate_test.go +++ b/services/search/pkg/aggregation/validate_test.go @@ -27,6 +27,7 @@ func fieldTypes(field string) string { var _ = Describe("ValidateOptions", func() { numeric := &searchsvc.BucketDefinition{Ranges: []*searchsvc.BucketRange{{From: "1980", To: "1990"}}} dates := &searchsvc.BucketDefinition{Ranges: []*searchsvc.BucketRange{{From: "2018-08-11T00:00:00Z"}}} + sum := &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_SUM} DescribeTable("accepts an aggregation that fits its field", func(opt *searchsvc.AggregationOption) { @@ -37,6 +38,7 @@ var _ = Describe("ValidateOptions", func() { Entry("terms on a number", &searchsvc.AggregationOption{Field: "audio.year"}), Entry("numeric ranges on a number", &searchsvc.AggregationOption{Field: "audio.year", BucketDefinition: numeric}), Entry("date ranges on a date", &searchsvc.AggregationOption{Field: "photo.takenDateTime", BucketDefinition: dates}), + Entry("a metric on a number", &searchsvc.AggregationOption{Field: "audio.year", MetricDefinition: sum}), ) DescribeTable("rejects an aggregation the index cannot answer", @@ -49,6 +51,8 @@ var _ = Describe("ValidateOptions", func() { Entry("terms on a path", &searchsvc.AggregationOption{Field: "Path"}), Entry("terms on a facet object", &searchsvc.AggregationOption{Field: "audio"}), Entry("terms on a geopoint", &searchsvc.AggregationOption{Field: "location"}), + Entry("a metric on a keyword", &searchsvc.AggregationOption{Field: "audio.artist", MetricDefinition: sum}), + Entry("a metric on a date", &searchsvc.AggregationOption{Field: "photo.takenDateTime", MetricDefinition: sum}), Entry("numeric ranges on a keyword", &searchsvc.AggregationOption{Field: "audio.artist", BucketDefinition: numeric}), Entry("numeric ranges on a date", &searchsvc.AggregationOption{Field: "photo.takenDateTime", BucketDefinition: numeric}), Entry("date ranges on a number", &searchsvc.AggregationOption{Field: "audio.year", BucketDefinition: dates}), diff --git a/services/search/pkg/bleve/aggregations.go b/services/search/pkg/bleve/aggregations.go index 142121eda1..503c6e5ffb 100644 --- a/services/search/pkg/bleve/aggregations.go +++ b/services/search/pkg/bleve/aggregations.go @@ -69,9 +69,13 @@ type fieldValues struct { type bucketAcc struct { counts map[string]int64 + metric *searchService.Metric } -func newBucketAcc() *bucketAcc { +func newBucketAcc(l *aggLevel) *bucketAcc { + if l.kind == aggregation.KindMetric { + return &bucketAcc{metric: &searchService.Metric{Kind: l.opt.GetMetricDefinition().GetKind()}} + } return &bucketAcc{counts: map[string]int64{}} } @@ -98,7 +102,7 @@ func newAggCollector(aggs []*searchService.AggregationOption) (*aggCollector, er return nil, err } c.register(l) - c.roots = append(c.roots, &aggRoot{level: l, acc: newBucketAcc()}) + c.roots = append(c.roots, &aggRoot{level: l, acc: newBucketAcc(l)}) } return c, nil } @@ -180,6 +184,10 @@ func (c *aggCollector) visit(field string, term []byte) { func (c *aggCollector) fold(a *bucketAcc, l *aggLevel) { fv := c.fields[l.opt.GetField()] switch l.kind { + case aggregation.KindMetric: + for _, raw := range fv.numbers { + aggregation.Observe(a.metric, numeric.Int64ToFloat64(raw)) + } case aggregation.KindTerms: if l.numericTerms() { for _, raw := range fv.numbers { @@ -223,6 +231,10 @@ func (c *aggCollector) results() []*searchService.AggregationResult { func (a *bucketAcc) result(l *aggLevel) *searchService.AggregationResult { r := &searchService.AggregationResult{Field: l.opt.GetField()} + if l.kind == aggregation.KindMetric { + r.Metric = a.metric + return r + } counted := make(map[string]*searchService.Bucket, len(a.counts)) for key, count := range a.counts { diff --git a/services/search/pkg/opensearch/internal/aggs/aggs.go b/services/search/pkg/opensearch/internal/aggs/aggs.go index dfbff02d6e..fc0217fad8 100644 --- a/services/search/pkg/opensearch/internal/aggs/aggs.go +++ b/services/search/pkg/opensearch/internal/aggs/aggs.go @@ -38,6 +38,10 @@ func aggName(position int) string { func build(opt *searchsvc.AggregationOption) (map[string]any, error) { var entry map[string]any switch aggregation.KindOf(opt) { + case aggregation.KindMetric: + // all accumulators, whatever the kind: the service layer merges them + // across spaces and reduces to the kind's value + return map[string]any{"stats": map[string]any{"field": opt.GetField()}}, nil case aggregation.KindRange: ranges, err := buildRanges(opt.GetField(), opt.GetBucketDefinition().GetRanges()) if err != nil { @@ -113,6 +117,9 @@ func parse(opts []*searchsvc.AggregationOption, node aggNode) ([]*searchsvc.Aggr } func result(opt *searchsvc.AggregationOption, raw json.RawMessage) (*searchsvc.AggregationResult, error) { + if aggregation.KindOf(opt) == aggregation.KindMetric { + return metricResult(opt, raw) + } var body struct { Buckets []json.RawMessage `json:"buckets"` SumOtherDocCount int64 `json:"sum_other_doc_count"` @@ -173,3 +180,29 @@ func bucketKey(key any, asString *string) string { } return "" } + +// metricResult reads a stats aggregation; min and max are null without a +// single value. +func metricResult(opt *searchsvc.AggregationOption, raw json.RawMessage) (*searchsvc.AggregationResult, error) { + res := &searchsvc.AggregationResult{ + Field: opt.GetField(), + Metric: &searchsvc.Metric{Kind: opt.GetMetricDefinition().GetKind()}, + } + if len(raw) == 0 { + return res, nil + } + var body struct { + Count int64 `json:"count"` + Sum float64 `json:"sum"` + Min *float64 `json:"min"` + Max *float64 `json:"max"` + } + if err := json.Unmarshal(raw, &body); err != nil { + return nil, fmt.Errorf("decode metric aggregation on %s: %w", opt.GetField(), err) + } + if body.Count == 0 || body.Min == nil || body.Max == nil { + return res, nil + } + res.Metric.Count, res.Metric.Sum, res.Metric.Min, res.Metric.Max = body.Count, body.Sum, *body.Min, *body.Max + return res, nil +} diff --git a/services/search/pkg/opensearch/internal/aggs/aggs_test.go b/services/search/pkg/opensearch/internal/aggs/aggs_test.go index c7925a06ba..e333418e2d 100644 --- a/services/search/pkg/opensearch/internal/aggs/aggs_test.go +++ b/services/search/pkg/opensearch/internal/aggs/aggs_test.go @@ -123,6 +123,48 @@ var _ = Describe("Aggregations", func() { Expect(keys(out[0])).To(Equal([]string{"1980..1990=0", "1970..1980=4", "1990..=0"})) }) + DescribeTable("asks for all accumulators of a metric, whatever its kind", + func(kind searchsvc.MetricKind) { + opt := &searchsvc.AggregationOption{Field: "audio.duration", MetricDefinition: &searchsvc.MetricDefinition{Kind: kind}} + stats := build(opt)["a_0"].(map[string]any)["stats"].(map[string]any) + Expect(stats["field"]).To(Equal("audio.duration")) + + m := parse(`{"a_0": {"count": 100, "min": 30000.0, "max": 500000.0, "avg": 245000.0, "sum": 24500000.0}}`, opt)[0].GetMetric() + Expect(m.GetKind()).To(Equal(kind)) + Expect(m.GetCount()).To(Equal(int64(100))) + Expect(m.GetSum()).To(Equal(24500000.0)) + Expect(m.GetMin()).To(Equal(30000.0)) + Expect(m.GetMax()).To(Equal(500000.0)) + Expect(m.Value).To(BeNil(), "the service layer reduces to the value after the merge") + }, + Entry("sum", searchsvc.MetricKind_METRIC_KIND_SUM), + Entry("min", searchsvc.MetricKind_METRIC_KIND_MIN), + Entry("max", searchsvc.MetricKind_METRIC_KIND_MAX), + Entry("avg", searchsvc.MetricKind_METRIC_KIND_AVG), + ) + + It("leaves a metric without a single value empty instead of reading null as zero", func() { + m := parse(`{"a_0": {"count": 0, "min": null, "max": null, "avg": null, "sum": 0.0}}`, + &searchsvc.AggregationOption{Field: "audio.duration", MetricDefinition: &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_MIN}})[0].GetMetric() + Expect(m.GetKind()).To(Equal(searchsvc.MetricKind_METRIC_KIND_MIN)) + Expect(m.GetCount()).To(BeZero()) + }) + + It("answers one result per aggregation, in request order", func() { + out := parse(`{}`, + &searchsvc.AggregationOption{Field: "audio.year", MetricDefinition: &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_SUM}}, + &searchsvc.AggregationOption{Field: "audio.artist"}, + &searchsvc.AggregationOption{Field: "audio.year", BucketDefinition: &searchsvc.BucketDefinition{Ranges: []*searchsvc.BucketRange{{To: "1980"}}}}, + &searchsvc.AggregationOption{Field: "audio.year", MetricDefinition: &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_MAX}}, + ) + Expect(out).To(HaveLen(4)) + Expect(out[0].GetMetric().GetKind()).To(Equal(searchsvc.MetricKind_METRIC_KIND_SUM)) + Expect(out[1].GetMetric()).To(BeNil()) + Expect(out[1].GetBuckets()).To(BeEmpty()) + Expect(keys(out[2])).To(Equal([]string{"..1980=0"})) + Expect(out[3].GetMetric().GetKind()).To(Equal(searchsvc.MetricKind_METRIC_KIND_MAX)) + }) + DescribeTable("reports a result it cannot decode instead of dropping it", func(opt *searchsvc.AggregationOption, response string) { _, err := aggs.Parse([]*searchsvc.AggregationOption{opt}, json.RawMessage(response)) @@ -131,5 +173,8 @@ var _ = Describe("Aggregations", func() { Entry("malformed json", &searchsvc.AggregationOption{Field: "audio.artist"}, `{`), Entry("a terms aggregation", &searchsvc.AggregationOption{Field: "audio.artist"}, `{"a_0": {"buckets": "none"}}`), Entry("a bucket", &searchsvc.AggregationOption{Field: "audio.artist"}, `{"a_0": {"buckets": [{"key": "Saxon", "doc_count": "many"}]}}`), + Entry("a metric", + &searchsvc.AggregationOption{Field: "audio.year", MetricDefinition: &searchsvc.MetricDefinition{Kind: searchsvc.MetricKind_METRIC_KIND_SUM}}, + `{"a_0": {"count": "many"}}`), ) }) diff --git a/services/search/pkg/parity/README.md b/services/search/pkg/parity/README.md index 3cb800df32..8488374ee6 100644 --- a/services/search/pkg/parity/README.md +++ b/services/search/pkg/parity/README.md @@ -741,9 +741,12 @@ Fixtures: | AGG-03 | `mediatype:audio` reads `artist and album buckets in one request` | audio.albu... Steel=2, audio.albu...Spades=1, audio.album Bomber=2, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.albu... Steel=2, audio.albu...Spades=1, audio.album Bomber=2, audio.artist Motörhead=3, audio.artist Saxon=2 | audio.albu... Steel=2, audio.albu...Spades=1, audio.album Bomber=2, audio.artist Motörhead=3, audio.artist Saxon=2 | ✅ | | AGG-04 | `mediatype:audio` reads `audio.year buckets per decade` | audio.year 1970..1980=2, audio.year 1980..1990=1, audio.year 1990..2000=1, audio.year 2000..2010=3 | audio.year 1970..1980=2, audio.year 1980..1990=1, audio.year 1990..2000=1, audio.year 2000..2010=3 | audio.year 1970..1980=2, audio.year 1980..1990=1, audio.year 1990..2000=1, audio.year 2000..2010=3 | ✅ | | AGG-05 | `mediatype:audio` reads `open-ended audio.year ranges` | audio.year ..1990=3, audio.year 2000..=3 | audio.year ..1990=3, audio.year 2000..=3 | audio.year ..1990=3, audio.year 2000..=3 | ✅ | +| AGG-06 | `mediatype:audio` reads `top-level metrics on audio.year` | audio.year max=2009, audio.year min=1971, audio.year sum=13942, audio.year... count=7 | audio.year max=2009, audio.year min=1971, audio.year sum=13942, audio.year... count=7 | audio.year max=2009, audio.year min=1971, audio.year sum=13942, audio.year... count=7 | ✅ | | AGG-07 | `mediatype:image` reads `photo.takenDateTime buckets per date range, an empty range counts zero` | photo.take...00:00Z=0, photo.take...00:00Z=1, photo.take...00:00Z=2, photo.take...00:00Z=2 | photo.take...00:00Z=0, photo.take...00:00Z=1, photo.take...00:00Z=2, photo.take...00:00Z=2 | photo.take...00:00Z=0, photo.take...00:00Z=1, photo.take...00:00Z=2, photo.take...00:00Z=2 | ✅ | | AGG-08 | `mediatype:image` reads `open-ended date ranges` | photo.take...00:00Z=3, photo.take...:00Z..=1 | photo.take...00:00Z=3, photo.take...:00Z..=1 | photo.take...00:00Z=3, photo.take...:00Z..=1 | ✅ | | AGG-09 | `mediatype:image` reads `malformed date range bound` | bad request | bad request | bad request | ✅ | +| AGG-18 | `mediatype:audio` reads `two range aggregations and a metric on audio.year stay apart` | audio.year ..1980=2, audio.year ..2000=4, audio.year 1980..=5, audio.year 2000..=3, audio.year max=2009 | audio.year ..1980=2, audio.year ..2000=4, audio.year 1980..=5, audio.year 2000..=3, audio.year max=2009 | audio.year ..1980=2, audio.year ..2000=4, audio.year 1980..=5, audio.year 2000..=3, audio.year max=2009 | ✅ | +| AGG-21 | `mediatype:image` reads `a metric without a single value has none` | audio.year avg none, audio.year min none, audio.year sum none | audio.year avg none, audio.year min none, audio.year sum none | audio.year avg none, audio.year min none, audio.year sum none | ✅ | | AGG-34 | `mediatype:audio` reads `term buckets on the numeric audio.year` | audio.year 1971=1, audio.year 1975=1, audio.year 1982=1, audio.year 1999=1, audio.year 2001=1, audio.year 2005=1, audio.year 2009=1 | audio.year 1971=1, audio.year 1975=1, audio.year 1982=1, audio.year 1999=1, audio.year 2001=1, audio.year 2005=1, audio.year 2009=1 | audio.year 1971=1, audio.year 1975=1, audio.year 1982=1, audio.year 1999=1, audio.year 2001=1, audio.year 2005=1, audio.year 2009=1 | ✅ | | AGG-37 | `mediatype:audio` reads `term buckets on the bool audio.hasDrm, spelled true and false` | audio.hasDrm false=1, audio.hasDrm true=1 | audio.hasDrm false=1, audio.hasDrm true=1 | audio.hasDrm false=1, audio.hasDrm true=1 | ✅ | | AGG-39 | `mediatype:audio` reads `no bucket for the empty Title of every match` | no match | no match | no match | ✅ | diff --git a/services/search/pkg/parity/aggregations_test.go b/services/search/pkg/parity/aggregations_test.go index f25c62563c..46ad50148e 100644 --- a/services/search/pkg/parity/aggregations_test.go +++ b/services/search/pkg/parity/aggregations_test.go @@ -3,6 +3,7 @@ package parity import ( "context" "fmt" + "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -12,6 +13,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/conversions" searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" + "github.com/opencloud-eu/opencloud/services/search/pkg/aggregation" "github.com/opencloud-eu/opencloud/services/search/pkg/search" ) @@ -96,6 +98,10 @@ func aggregationCases() []aggCase { ranges := func(rs ...*searchService.BucketRange) *searchService.BucketDefinition { return &searchService.BucketDefinition{Ranges: rs} } + metric := func(field string, kind searchService.MetricKind) *searchService.AggregationOption { + return &searchService.AggregationOption{Field: field, MetricDefinition: &searchService.MetricDefinition{Kind: kind}} + } + return []aggCase{ {id: 1, query: "mediatype:audio", reads: "term buckets on audio.artist", aggs: []*searchService.AggregationOption{{Field: "audio.artist", Size: 10}}, @@ -121,6 +127,14 @@ func aggregationCases() []aggCase { &searchService.BucketRange{From: "2000"}, )}}, want: []string{"audio.year ..1990=3", "audio.year 2000..=3"}}, + {id: 6, query: "mediatype:audio", reads: "top-level metrics on audio.year", + aggs: []*searchService.AggregationOption{ + metric("audio.year", searchService.MetricKind_METRIC_KIND_SUM), + metric("audio.year", searchService.MetricKind_METRIC_KIND_MIN), + metric("audio.year", searchService.MetricKind_METRIC_KIND_MAX), + metric("audio.year", searchService.MetricKind_METRIC_KIND_AVG), + }, + want: []string{"audio.year sum=13942", "audio.year min=1971", "audio.year max=2009", "audio.year avg sum=13942 count=7"}}, {id: 7, query: "mediatype:image", reads: "photo.takenDateTime buckets per date range, an empty range counts zero", aggs: []*searchService.AggregationOption{{Field: "photo.takenDateTime", BucketDefinition: ranges( &searchService.BucketRange{From: "2018-08-11T00:00:00Z", To: "2018-08-12T00:00:00Z"}, @@ -145,6 +159,24 @@ func aggregationCases() []aggCase { &searchService.BucketRange{From: "2018-08-11T00:00:00Z", To: "not-a-date"}, )}}, wantBadRequest: true}, + {id: 18, query: "mediatype:audio", reads: "two range aggregations and a metric on audio.year stay apart", + aggs: []*searchService.AggregationOption{ + {Field: "audio.year", BucketDefinition: ranges(&searchService.BucketRange{To: "1980"}, &searchService.BucketRange{From: "1980"})}, + {Field: "audio.year", BucketDefinition: ranges(&searchService.BucketRange{To: "2000"}, &searchService.BucketRange{From: "2000"})}, + metric("audio.year", searchService.MetricKind_METRIC_KIND_MAX), + }, + want: []string{ + "audio.year ..1980=2", "audio.year 1980..=5", + "audio.year ..2000=4", "audio.year 2000..=3", + "audio.year max=2009", + }}, + {id: 21, query: "mediatype:image", reads: "a metric without a single value has none", + aggs: []*searchService.AggregationOption{ + metric("audio.year", searchService.MetricKind_METRIC_KIND_SUM), + metric("audio.year", searchService.MetricKind_METRIC_KIND_MIN), + metric("audio.year", searchService.MetricKind_METRIC_KIND_AVG), + }, + want: []string{"audio.year sum none", "audio.year min none", "audio.year avg none"}}, {id: 34, query: "mediatype:audio", reads: "term buckets on the numeric audio.year", aggs: []*searchService.AggregationOption{{Field: "audio.year"}}, want: []string{ @@ -163,7 +195,7 @@ func aggregationCases() []aggCase { } // renderAggregations flattens an answer into comparable strings, one per -// bucket. +// bucket or metric. func renderAggregations(resp *searchService.SearchIndexResponse, err error) []string { if err != nil { if _, ok := err.(errtypes.BadRequest); ok { @@ -181,6 +213,18 @@ func renderAggregations(resp *searchService.SearchIndexResponse, err error) []st } func renderAggregation(prefix string, a *searchService.AggregationResult) []string { + if m := a.GetMetric(); m != nil { + kind := strings.ToLower(strings.TrimPrefix(m.GetKind().String(), "METRIC_KIND_")) + value, ok := aggregation.MetricValue(m) + switch { + case !ok: + return []string{prefix + fmt.Sprintf("%s %s none", a.Field, kind)} + case m.GetKind() == searchService.MetricKind_METRIC_KIND_AVG: + return []string{prefix + fmt.Sprintf("%s avg sum=%v count=%d", a.Field, m.GetSum(), m.GetCount())} + } + return []string{prefix + fmt.Sprintf("%s %s=%v", a.Field, kind, value)} + } + out := []string{} for _, b := range a.Buckets { out = append(out, prefix+fmt.Sprintf("%s %s=%d", a.Field, b.Key, b.Count))