diff --git a/protogen/gen/opencloud/services/search/v0/search.pb.go b/protogen/gen/opencloud/services/search/v0/search.pb.go index 02e4f718b6..492f88021c 100644 --- a/protogen/gen/opencloud/services/search/v0/search.pb.go +++ b/protogen/gen/opencloud/services/search/v0/search.pb.go @@ -98,6 +98,11 @@ type SearchRequest struct { // fragments (e.g. `audio.artist:"Pink Floyd"`). Combined with `query` via AND // and matched case-sensitively/exactly. Passed through from the graph layer. AggregationFilters []string `protobuf:"bytes,6,rep,name=aggregation_filters,json=aggregationFilters,proto3" json:"aggregation_filters,omitempty"` + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Only a subset of the + // indexed fields is sortable; the graph service validates this before + // forwarding the request. + OrderBy []*SortProperty `protobuf:"bytes,7,rep,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` } func (x *SearchRequest) Reset() { @@ -174,6 +179,13 @@ func (x *SearchRequest) GetAggregationFilters() []string { return nil } +func (x *SearchRequest) GetOrderBy() []*SortProperty { + if x != nil { + return x.OrderBy + } + return nil +} + type SearchResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -265,6 +277,10 @@ type SearchIndexRequest struct { // Optional. Decoded aggregation filters as KQL fragments; the engine parses // each, forces exact/case-sensitive matching, and ANDs them with `query`. AggregationFilters []string `protobuf:"bytes,6,rep,name=aggregation_filters,json=aggregationFilters,proto3" json:"aggregation_filters,omitempty"` + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Each backend translates + // this to its native sort (bleve: SortBy, OpenSearch: sort clause). + OrderBy []*SortProperty `protobuf:"bytes,7,rep,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` } func (x *SearchIndexRequest) Reset() { @@ -341,6 +357,13 @@ func (x *SearchIndexRequest) GetAggregationFilters() []string { return nil } +func (x *SearchIndexRequest) GetOrderBy() []*SortProperty { + if x != nil { + return x.OrderBy + } + return nil +} + type SearchIndexResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -508,6 +531,68 @@ func (x *AggregationOption) GetMetricKind() MetricKind { return MetricKind_METRIC_KIND_UNSPECIFIED } +type SortProperty struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Required. The field to sort on, in graph notation ("name", "size", + // "lastModifiedDateTime", "mimeType" or a scalar facet field such as + // "photo.takenDateTime" or "audio.artist"). A field is sortable when it is + // indexed as a scalar in both backends AND carried on the Match entity + // (the service layer needs the sort key to merge per-space result + // streams); see the search package's IsSortableField. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Optional. Sort in descending order. Defaults to ascending. + IsDescending bool `protobuf:"varint,2,opt,name=is_descending,json=isDescending,proto3" json:"is_descending,omitempty"` +} + +func (x *SortProperty) Reset() { + *x = SortProperty{} + 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 *SortProperty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SortProperty) ProtoMessage() {} + +func (x *SortProperty) 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 SortProperty.ProtoReflect.Descriptor instead. +func (*SortProperty) Descriptor() ([]byte, []int) { + return file_opencloud_services_search_v0_search_proto_rawDescGZIP(), []int{5} +} + +func (x *SortProperty) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SortProperty) GetIsDescending() bool { + if x != nil { + return x.IsDescending + } + return false +} + type BucketDefinition struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -525,7 +610,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) } @@ -538,7 +623,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 { @@ -551,7 +636,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() string { @@ -596,7 +681,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) } @@ -609,7 +694,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 { @@ -622,7 +707,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 { @@ -664,7 +749,7 @@ type AggregationResult struct { 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) } @@ -677,7 +762,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 { @@ -690,7 +775,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 { @@ -751,7 +836,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[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -764,7 +849,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[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -777,7 +862,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{9} } func (x *Bucket) GetKey() string { @@ -822,7 +907,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[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -835,7 +920,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[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -848,7 +933,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{10} } func (x *IndexSpaceRequest) GetSpaceId() string { @@ -899,7 +984,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[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -912,7 +997,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[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -925,7 +1010,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{11} } func (x *IndexSpaceResponse) GetSpaceId() string { @@ -984,7 +1069,7 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc0, 0x02, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x8d, 0x03, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61, @@ -1004,207 +1089,221 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x73, 0x12, 0x35, 0x0a, 0x13, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x12, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x73, 0x22, 0xf1, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x6d, - 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, - 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x61, 0x74, 0x63, - 0x68, 0x52, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, - 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, 0x63, - 0x68, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, - 0x4d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, - 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 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, 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, 0xc5, 0x02, 0x0a, - 0x12, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x08, 0x70, 0x61, - 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, - 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x71, - 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, - 0x79, 0x12, 0x3f, 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, - 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x03, 0x72, - 0x65, 0x66, 0x12, 0x59, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x73, 0x12, 0x4b, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, + 0x72, 0x5f, 0x62, 0x79, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 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, 0x53, 0x6f, 0x72, 0x74, 0x50, 0x72, + 0x6f, 0x70, 0x65, 0x72, 0x74, 0x79, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x07, 0x6f, 0x72, + 0x64, 0x65, 0x72, 0x42, 0x79, 0x22, 0xf1, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x6d, 0x61, 0x74, 0x63, + 0x68, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, + 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x52, 0x07, + 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, + 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x61, 0x74, + 0x63, 0x68, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 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, 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, 0x92, 0x03, 0x0a, 0x12, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, + 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x09, 0x70, + 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, + 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x3f, + 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, + 0x59, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, + 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 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, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0c, 0x61, 0x67, + 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x13, 0x61, 0x67, + 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x12, 0x61, + 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x73, 0x12, 0x4b, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x79, 0x18, 0x07, 0x20, + 0x03, 0x28, 0x0b, 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, 0x53, 0x6f, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x79, 0x42, + 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x79, 0x22, 0xf6, + 0x01, 0x0a, 0x13, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, + 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x52, 0x07, 0x6d, 0x61, + 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, + 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, + 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, + 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x61, 0x74, 0x63, 0x68, + 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 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, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, - 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x35, 0x0a, - 0x13, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, 0x69, 0x6c, - 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, - 0x52, 0x12, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, - 0x74, 0x65, 0x72, 0x73, 0x22, 0xf6, 0x01, 0x0a, 0x13, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, - 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, - 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, - 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x74, - 0x63, 0x68, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x74, 0x6f, 0x74, 0x61, - 0x6c, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x61, 0x67, 0x67, 0x72, - 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 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, 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, 0xd9, 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, 0x04, 0x73, - 0x69, 0x7a, 0x65, 0x12, 0x61, 0x0a, 0x11, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x64, 0x65, - 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 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, 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, 0x12, 0x60, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x5f, 0x61, 0x67, - 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, - 0x32, 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, - 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, - 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x4f, 0x0a, 0x0b, 0x6d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x05, 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, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0a, 0x6d, - 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x22, 0xb8, 0x01, 0x0a, 0x10, 0x42, 0x75, - 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x17, - 0x0a, 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 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, 0xf2, 0x01, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, - 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, + 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xd9, 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, 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, + 0x65, 0x6c, 0x64, 0x12, 0x18, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x61, 0x0a, + 0x11, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x03, 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, 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, + 0x12, 0x60, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 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, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x04, 0xe2, 0x41, 0x01, + 0x01, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x12, 0x4f, 0x0a, 0x0b, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, + 0x64, 0x18, 0x05, 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, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, + 0x69, 0x6e, 0x64, 0x22, 0x47, 0x0a, 0x0c, 0x53, 0x6f, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x70, 0x65, + 0x72, 0x74, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 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, 0x22, 0xb8, 0x01, 0x0a, + 0x10, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 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, 0x07, 0x62, 0x75, 0x63, 0x6b, - 0x65, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x49, 0x0a, 0x0b, 0x6d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 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, 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, - 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x01, 0x52, 0x03, 0x73, 0x75, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xc6, 0x01, 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, 0x12, - 0x38, 0x0a, 0x18, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, - 0x69, 0x6c, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x16, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x69, - 0x6c, 0x74, 0x65, 0x72, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x5a, 0x0a, 0x10, 0x73, 0x75, 0x62, - 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, - 0x03, 0x28, 0x0b, 0x32, 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, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 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, 0xd1, 0x01, 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, - 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, 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, + 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, 0xf2, 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, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x49, 0x0a, 0x0b, + 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 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, 0x0a, 0x6d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x01, 0x52, 0x03, 0x73, 0x75, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, + 0xc6, 0x01, 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, 0x12, 0x38, 0x0a, 0x18, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x5a, 0x0a, 0x10, + 0x73, 0x75, 0x62, 0x5f, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 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, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x41, 0x67, 0x67, 0x72, + 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 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, + 0xd1, 0x01, 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, 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, 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, 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, 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, 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 ( @@ -1220,7 +1319,7 @@ func file_opencloud_services_search_v0_search_proto_rawDescGZIP() []byte { } var file_opencloud_services_search_v0_search_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_opencloud_services_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_opencloud_services_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_opencloud_services_search_v0_search_proto_goTypes = []interface{}{ (MetricKind)(0), // 0: opencloud.services.search.v0.MetricKind (*SearchRequest)(nil), // 1: opencloud.services.search.v0.SearchRequest @@ -1228,44 +1327,47 @@ var file_opencloud_services_search_v0_search_proto_goTypes = []interface{}{ (*SearchIndexRequest)(nil), // 3: opencloud.services.search.v0.SearchIndexRequest (*SearchIndexResponse)(nil), // 4: opencloud.services.search.v0.SearchIndexResponse (*AggregationOption)(nil), // 5: opencloud.services.search.v0.AggregationOption - (*BucketDefinition)(nil), // 6: opencloud.services.search.v0.BucketDefinition - (*BucketRange)(nil), // 7: opencloud.services.search.v0.BucketRange - (*AggregationResult)(nil), // 8: opencloud.services.search.v0.AggregationResult - (*Bucket)(nil), // 9: opencloud.services.search.v0.Bucket - (*IndexSpaceRequest)(nil), // 10: opencloud.services.search.v0.IndexSpaceRequest - (*IndexSpaceResponse)(nil), // 11: opencloud.services.search.v0.IndexSpaceResponse - (*v0.Reference)(nil), // 12: opencloud.messages.search.v0.Reference - (*v0.Match)(nil), // 13: opencloud.messages.search.v0.Match - (*durationpb.Duration)(nil), // 14: google.protobuf.Duration + (*SortProperty)(nil), // 6: opencloud.services.search.v0.SortProperty + (*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 } var file_opencloud_services_search_v0_search_proto_depIdxs = []int32{ - 12, // 0: opencloud.services.search.v0.SearchRequest.ref:type_name -> opencloud.messages.search.v0.Reference + 13, // 0: opencloud.services.search.v0.SearchRequest.ref:type_name -> opencloud.messages.search.v0.Reference 5, // 1: opencloud.services.search.v0.SearchRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 13, // 2: opencloud.services.search.v0.SearchResponse.matches:type_name -> opencloud.messages.search.v0.Match - 8, // 3: opencloud.services.search.v0.SearchResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 12, // 4: opencloud.services.search.v0.SearchIndexRequest.ref:type_name -> opencloud.messages.search.v0.Reference - 5, // 5: opencloud.services.search.v0.SearchIndexRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 13, // 6: opencloud.services.search.v0.SearchIndexResponse.matches:type_name -> opencloud.messages.search.v0.Match - 8, // 7: opencloud.services.search.v0.SearchIndexResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 6, // 8: opencloud.services.search.v0.AggregationOption.bucket_definition:type_name -> opencloud.services.search.v0.BucketDefinition - 5, // 9: opencloud.services.search.v0.AggregationOption.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationOption - 0, // 10: opencloud.services.search.v0.AggregationOption.metric_kind:type_name -> opencloud.services.search.v0.MetricKind - 7, // 11: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange - 9, // 12: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket - 0, // 13: opencloud.services.search.v0.AggregationResult.metric_kind:type_name -> opencloud.services.search.v0.MetricKind - 8, // 14: opencloud.services.search.v0.Bucket.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationResult - 14, // 15: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration - 1, // 16: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest - 10, // 17: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest - 3, // 18: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest - 2, // 19: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse - 11, // 20: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse - 4, // 21: opencloud.services.search.v0.IndexProvider.Search:output_type -> opencloud.services.search.v0.SearchIndexResponse - 19, // [19:22] is the sub-list for method output_type - 16, // [16:19] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 6, // 2: opencloud.services.search.v0.SearchRequest.order_by:type_name -> opencloud.services.search.v0.SortProperty + 14, // 3: opencloud.services.search.v0.SearchResponse.matches:type_name -> opencloud.messages.search.v0.Match + 9, // 4: opencloud.services.search.v0.SearchResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 13, // 5: opencloud.services.search.v0.SearchIndexRequest.ref:type_name -> opencloud.messages.search.v0.Reference + 5, // 6: opencloud.services.search.v0.SearchIndexRequest.aggregations:type_name -> opencloud.services.search.v0.AggregationOption + 6, // 7: opencloud.services.search.v0.SearchIndexRequest.order_by:type_name -> opencloud.services.search.v0.SortProperty + 14, // 8: opencloud.services.search.v0.SearchIndexResponse.matches:type_name -> opencloud.messages.search.v0.Match + 9, // 9: opencloud.services.search.v0.SearchIndexResponse.aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 7, // 10: opencloud.services.search.v0.AggregationOption.bucket_definition:type_name -> opencloud.services.search.v0.BucketDefinition + 5, // 11: opencloud.services.search.v0.AggregationOption.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationOption + 0, // 12: opencloud.services.search.v0.AggregationOption.metric_kind:type_name -> opencloud.services.search.v0.MetricKind + 8, // 13: opencloud.services.search.v0.BucketDefinition.ranges:type_name -> opencloud.services.search.v0.BucketRange + 10, // 14: opencloud.services.search.v0.AggregationResult.buckets:type_name -> opencloud.services.search.v0.Bucket + 0, // 15: opencloud.services.search.v0.AggregationResult.metric_kind:type_name -> opencloud.services.search.v0.MetricKind + 9, // 16: opencloud.services.search.v0.Bucket.sub_aggregations:type_name -> opencloud.services.search.v0.AggregationResult + 15, // 17: opencloud.services.search.v0.IndexSpaceResponse.space_duration:type_name -> google.protobuf.Duration + 1, // 18: opencloud.services.search.v0.SearchProvider.Search:input_type -> opencloud.services.search.v0.SearchRequest + 11, // 19: opencloud.services.search.v0.SearchProvider.IndexSpace:input_type -> opencloud.services.search.v0.IndexSpaceRequest + 3, // 20: opencloud.services.search.v0.IndexProvider.Search:input_type -> opencloud.services.search.v0.SearchIndexRequest + 2, // 21: opencloud.services.search.v0.SearchProvider.Search:output_type -> opencloud.services.search.v0.SearchResponse + 12, // 22: opencloud.services.search.v0.SearchProvider.IndexSpace:output_type -> opencloud.services.search.v0.IndexSpaceResponse + 4, // 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() } @@ -1335,7 +1437,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.(*SortProperty); i { case 0: return &v.state case 1: @@ -1347,7 +1449,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: @@ -1359,7 +1461,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: @@ -1371,7 +1473,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: @@ -1383,7 +1485,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.(*Bucket); i { case 0: return &v.state case 1: @@ -1395,6 +1497,18 @@ 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.(*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[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*IndexSpaceResponse); i { case 0: return &v.state @@ -1413,7 +1527,7 @@ func file_opencloud_services_search_v0_search_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_opencloud_services_search_v0_search_proto_rawDesc, NumEnums: 1, - NumMessages: 11, + NumMessages: 12, 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 54e0b20a07..ef667bc2b1 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -670,6 +670,13 @@ "type": "string" }, "description": "Optional. Decoded aggregation filters as KQL fragments; the engine parses\neach, forces exact/case-sensitive matching, and ANDs them with `query`." + }, + "orderBy": { + "type": "array", + "items": { + "$ref": "#/definitions/v0SortProperty" + }, + "description": "Optional. Fields to sort the matches by, in order of precedence. When\nempty, matches are sorted by relevance score. Each backend translates\nthis to its native sort (bleve: SortBy, OpenSearch: sort clause)." } } }, @@ -730,6 +737,13 @@ "type": "string" }, "description": "Optional. Decoded aggregation filters, one per selected bucket, as KQL\nfragments (e.g. `audio.artist:\"Pink Floyd\"`). Combined with `query` via AND\nand matched case-sensitively/exactly. Passed through from the graph layer." + }, + "orderBy": { + "type": "array", + "items": { + "$ref": "#/definitions/v0SortProperty" + }, + "description": "Optional. Fields to sort the matches by, in order of precedence. When\nempty, matches are sorted by relevance score. Only a subset of the\nindexed fields is sortable; the graph service validates this before\nforwarding the request." } } }, @@ -759,6 +773,19 @@ } } }, + "v0SortProperty": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Required. The field to sort on, in graph notation (\"name\", \"size\",\n\"lastModifiedDateTime\", \"mimeType\" or a scalar facet field such as\n\"photo.takenDateTime\" or \"audio.artist\"). A field is sortable when it is\nindexed as a scalar in both backends AND carried on the Match entity\n(the service layer needs the sort key to merge per-space result\nstreams); see the search package's IsSortableField." + }, + "isDescending": { + "type": "boolean", + "description": "Optional. Sort in descending order. Defaults to ascending." + } + } + }, "v0Video": { "type": "object", "properties": { diff --git a/protogen/proto/opencloud/services/search/v0/search.proto b/protogen/proto/opencloud/services/search/v0/search.proto index 79e8a75f1e..68acdf1f6e 100644 --- a/protogen/proto/opencloud/services/search/v0/search.proto +++ b/protogen/proto/opencloud/services/search/v0/search.proto @@ -80,6 +80,11 @@ message SearchRequest { // fragments (e.g. `audio.artist:"Pink Floyd"`). Combined with `query` via AND // and matched case-sensitively/exactly. Passed through from the graph layer. repeated string aggregation_filters = 6 [(google.api.field_behavior) = OPTIONAL]; + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Only a subset of the + // indexed fields is sortable; the graph service validates this before + // forwarding the request. + repeated SortProperty order_by = 7 [(google.api.field_behavior) = OPTIONAL]; } message SearchResponse { @@ -111,6 +116,10 @@ message SearchIndexRequest { // Optional. Decoded aggregation filters as KQL fragments; the engine parses // each, forces exact/case-sensitive matching, and ANDs them with `query`. repeated string aggregation_filters = 6 [(google.api.field_behavior) = OPTIONAL]; + // Optional. Fields to sort the matches by, in order of precedence. When + // empty, matches are sorted by relevance score. Each backend translates + // this to its native sort (bleve: SortBy, OpenSearch: sort clause). + repeated SortProperty order_by = 7 [(google.api.field_behavior) = OPTIONAL]; } message SearchIndexResponse { @@ -147,6 +156,18 @@ message AggregationOption { MetricKind metric_kind = 5 [(google.api.field_behavior) = OPTIONAL]; } +message SortProperty { + // Required. The field to sort on, in graph notation ("name", "size", + // "lastModifiedDateTime", "mimeType" or a scalar facet field such as + // "photo.takenDateTime" or "audio.artist"). A field is sortable when it is + // indexed as a scalar in both backends AND carried on the Match entity + // (the service layer needs the sort key to merge per-space result + // streams); see the search package's IsSortableField. + string name = 1; + // Optional. Sort in descending order. Defaults to ascending. + bool is_descending = 2; +} + enum MetricKind { METRIC_KIND_UNSPECIFIED = 0; METRIC_KIND_SUM = 1; diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go index 16e6c825b1..fd48333d65 100644 --- a/services/graph/pkg/service/v0/searchquery.go +++ b/services/graph/pkg/service/v0/searchquery.go @@ -43,6 +43,10 @@ func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) { errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) return } + if err := validateSortProperties(sr.SortProperties); err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } } th := r.Header.Get(revaCtx.TokenHeader) @@ -80,6 +84,7 @@ func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, PageSize: pageSize, Aggregations: libregraphAggregationsToSearch(sr.Aggregations), AggregationFilters: sr.AggregationFilters, + OrderBy: libregraphSortToSearch(sr.SortProperties), }) if err != nil { return libregraph.SearchResponse{}, err @@ -170,6 +175,34 @@ func validateAggregations(aggs []libregraph.AggregationOption) error { return nil } +// validateSortProperties rejects sorting by unknown or multivalued fields. +// Sortable are scalar fields carried on the search hit: name, size, +// lastModifiedDateTime, mimeType and the facet fields (photo.takenDateTime, +// audio.artist, image.width, ...); see search.IsSortableField. +func validateSortProperties(sortProperties []libregraph.SortProperty) error { + for _, sp := range sortProperties { + if !search.IsSortableField(sp.Name) { + return fmt.Errorf("field %q is not sortable; sortable are scalar hit fields such as name, size, lastModifiedDateTime, mimeType or photo.takenDateTime", sp.Name) + } + } + return nil +} + +func libregraphSortToSearch(in []libregraph.SortProperty) []*searchsvc.SortProperty { + if len(in) == 0 { + return nil + } + out := make([]*searchsvc.SortProperty, 0, len(in)) + for _, sp := range in { + p := &searchsvc.SortProperty{Name: sp.Name} + if sp.IsDescending != nil { + p.IsDescending = *sp.IsDescending + } + out = append(out, p) + } + return out +} + func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*searchsvc.AggregationOption { if len(in) == 0 { return nil diff --git a/services/graph/pkg/service/v0/searchquery_test.go b/services/graph/pkg/service/v0/searchquery_test.go index 2817f7188d..c89fc9d881 100644 --- a/services/graph/pkg/service/v0/searchquery_test.go +++ b/services/graph/pkg/service/v0/searchquery_test.go @@ -198,6 +198,84 @@ var _ = ginkgo.Describe("SearchQuery", func() { ginkgo.Entry("from+size overflow collapses", int32Ptr(1<<31-1), int32Ptr(500), int32(1<<31-1-500), int32(500)), ) + ginkgo.It("forwards sortProperties to the search service as order_by", func() { + var captured *searchsvc.SearchRequest + g := graphWithSearch(stubSearchService{ + search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + captured = req + return &searchsvc.SearchResponse{}, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "mediatype:image"}, + "sortProperties": [ + {"name": "photo.takenDateTime", "isDescending": true}, + {"name": "name"} + ] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + Expect(captured).ToNot(BeNil()) + Expect(captured.OrderBy).To(HaveLen(2)) + Expect(captured.OrderBy[0].Name).To(Equal("photo.takenDateTime")) + Expect(captured.OrderBy[0].IsDescending).To(BeTrue()) + Expect(captured.OrderBy[1].Name).To(Equal("name")) + Expect(captured.OrderBy[1].IsDescending).To(BeFalse()) + }) + + ginkgo.DescribeTable("accepts sorting by scalar hit fields", + func(field string) { + g := graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + return &searchsvc.SearchResponse{}, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "*"}, + "sortProperties": [{"name": "`+field+`"}] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + }, + ginkgo.Entry("name", "name"), + ginkgo.Entry("size", "size"), + ginkgo.Entry("lastModifiedDateTime", "lastModifiedDateTime"), + ginkgo.Entry("mimeType", "mimeType"), + ginkgo.Entry("photo.takenDateTime", "photo.takenDateTime"), + ginkgo.Entry("photo.iso", "photo.iso"), + ginkgo.Entry("audio.artist", "audio.artist"), + ginkgo.Entry("image.width", "image.width"), + ) + + ginkgo.DescribeTable("rejects sorting by unsortable fields with 400", + func(field string) { + g := graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + ginkgo.Fail("search service must not be called when validation fails") + return nil, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "mediatype:image"}, + "sortProperties": [{"name": "`+field+`"}] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring(field)) + }, + ginkgo.Entry("unknown field", "definitelyNotAField"), + ginkgo.Entry("multivalued field", "tags"), + ginkgo.Entry("internal index field name", "Mtime"), + ginkgo.Entry("bare audio facet", "audio"), + ginkgo.Entry("bare location facet", "location"), + ) + ginkgo.It("rejects a terms aggregation on a numeric field with 400", func() { g := graphWithSearch(stubSearchService{ search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index 6dfef81568..218d49e9a5 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -2,6 +2,7 @@ package bleve import ( "context" + "fmt" "math" "time" @@ -85,6 +86,24 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ bleveReq := bleve.NewSearchRequest(q) bleveReq.Highlight = bleve.NewHighlight() + // Sort natively in the index; the service layer re-establishes this order + // when merging matches across spaces. Score sorting (bleve's default) + // stays in place when no order_by is given. + if orderBy := sir.GetOrderBy(); len(orderBy) > 0 { + sortOrder := make([]string, 0, len(orderBy)+1) + for _, sp := range orderBy { + field, ok := search.SortIndexField(sp.GetName()) + if !ok { + return nil, errtypes.BadRequest(fmt.Sprintf("field %q is not sortable", sp.GetName())) + } + if sp.GetIsDescending() { + field = "-" + field + } + sortOrder = append(sortOrder, field) + } + bleveReq.SortBy(append(sortOrder, "-_score")) + } + switch { case sir.PageSize == -1: bleveReq.Size = math.MaxInt diff --git a/services/search/pkg/opensearch/backend.go b/services/search/pkg/opensearch/backend.go index c3d7a3e710..96f0d0ce18 100644 --- a/services/search/pkg/opensearch/backend.go +++ b/services/search/pkg/opensearch/backend.go @@ -127,6 +127,26 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ if err != nil { return nil, err } + // Sort natively in the index; the service layer re-establishes this order + // when merging matches across spaces. Score sorting (the default) stays in + // place when no order_by is given. Missing values sort last in both + // directions, matching the cross-space merge. + var sortClause []map[string]any + if orderBy := sir.GetOrderBy(); len(orderBy) > 0 { + sortClause = make([]map[string]any, 0, len(orderBy)+1) + for _, sp := range orderBy { + field, ok := search.SortIndexField(sp.GetName()) + if !ok { + return nil, errtypes.BadRequest(fmt.Sprintf("field %q is not sortable", sp.GetName())) + } + order := "asc" + if sp.GetIsDescending() { + order = "desc" + } + sortClause = append(sortClause, map[string]any{field: map[string]any{"order": order, "missing": "_last"}}) + } + sortClause = append(sortClause, map[string]any{"_score": map[string]any{"order": "desc"}}) + } req, err := osu.BuildSearchReq(&opensearchgoAPI.SearchReq{ Indices: []string{b.index}, @@ -147,6 +167,7 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ }, }, Aggs: builtAggs, + Sort: sortClause, }, ) if err != nil { diff --git a/services/search/pkg/opensearch/index.go b/services/search/pkg/opensearch/index.go index b68b71f302..79b8a98ec3 100644 --- a/services/search/pkg/opensearch/index.go +++ b/services/search/pkg/opensearch/index.go @@ -74,7 +74,6 @@ func buildResourceMapping() ([]byte, error) { if err != nil { return nil, err } - index := map[string]any{ "settings": map[string]any{ "number_of_shards": "1", diff --git a/services/search/pkg/opensearch/internal/osu/request.go b/services/search/pkg/opensearch/internal/osu/request.go index f6f4e238ee..608773c165 100644 --- a/services/search/pkg/opensearch/internal/osu/request.go +++ b/services/search/pkg/opensearch/internal/osu/request.go @@ -96,6 +96,7 @@ func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyPa type SearchBodyParams struct { Highlight *BodyParamHighlight `json:"highlight,omitempty"` Aggs map[string]any `json:"aggs,omitempty"` + Sort []map[string]any `json:"sort,omitempty"` } //----------------------------------------------------------------------------// diff --git a/services/search/pkg/search/search.go b/services/search/pkg/search/search.go index fb81135f2f..3c4af1f0cb 100644 --- a/services/search/pkg/search/search.go +++ b/services/search/pkg/search/search.go @@ -128,16 +128,6 @@ func ResolveReference(ctx context.Context, ref *provider.Reference, ri *provider type matchArray []*searchmsg.Match -func (ma matchArray) Len() int { - return len(ma) -} -func (ma matchArray) Swap(i, j int) { - ma[i], ma[j] = ma[j], ma[i] -} -func (ma matchArray) Less(i, j int) bool { - return ma[i].GetScore() > ma[j].GetScore() -} - func logDocCount(engine Engine, logger log.Logger) { c, err := engine.DocCount() if err != nil { diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index e3058684dd..07ad6df5bb 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -354,7 +354,28 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se } // compile one sorted list of matches from all spaces and apply the limit if needed - sort.Sort(matches) + // + // NOTE(perf): every space was searched with the caller's full page size, + // so serving one page costs O(spaces x page_size) fetched matches. With + // offset pagination (the graph layer maps from/size onto a single + // page_size) each deeper page re-fetches everything before it on top. + // Accepted for now. The known fix is field-sorted cursor pagination via + // the currently unused page_token request/response fields: each space + // then serves "sort key < cursor, limit size" and page cost becomes + // independent of depth. Pushing plain offsets down into the engines + // would only trim the transfer, not the per-space overfetch, so it is + // not worth doing on its own. + // + // Each engine already returns its matches in order_by order (or by score + // when no order_by is given); this merge re-establishes that order across + // spaces, with the score as tiebreaker. + orderBy := req.GetOrderBy() + sort.SliceStable(matches, func(i, j int) bool { + if c := CompareMatches(matches[i], matches[j], orderBy); c != 0 { + return c < 0 + } + return matches[i].GetScore() > matches[j].GetScore() + }) limit := req.PageSize if limit == 0 { limit = 200 @@ -620,6 +641,7 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest, Query: req.Query, Aggregations: req.GetAggregations(), AggregationFilters: req.GetAggregationFilters(), + OrderBy: req.GetOrderBy(), Ref: &searchmsg.Reference{ ResourceId: searchRootID, Path: searchPathPrefix, diff --git a/services/search/pkg/search/service_test.go b/services/search/pkg/search/service_test.go index 433817b3ca..c07acc89d2 100644 --- a/services/search/pkg/search/service_test.go +++ b/services/search/pkg/search/service_test.go @@ -16,6 +16,7 @@ import ( cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" "github.com/stretchr/testify/mock" "google.golang.org/grpc" + typespb "google.golang.org/protobuf/types/known/timestamppb" "github.com/opencloud-eu/opencloud/pkg/log" searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" @@ -275,6 +276,103 @@ var _ = Describe("Searchprovider", func() { }) }) + Context("with two personal spaces returning matches", func() { + var ( + spaceA = &sprovider.StorageSpace{ + Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$a!a"}, + Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "a", OpaqueId: "a"}, + Name: "space-a", + SpaceType: "personal", + } + spaceB = &sprovider.StorageSpace{ + Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$b!b"}, + Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "b", OpaqueId: "b"}, + Name: "space-b", + SpaceType: "personal", + } + + photoMatch = func(space string, name string, taken int64, score float32) *searchmsg.Match { + return &searchmsg.Match{ + Score: score, + Entity: &searchmsg.Entity{ + Ref: &searchmsg.Reference{ + ResourceId: &searchmsg.ResourceID{StorageId: "storageid", SpaceId: space, OpaqueId: space}, + Path: "./" + name, + }, + Id: &searchmsg.ResourceID{StorageId: "storageid", SpaceId: space, OpaqueId: name}, + Name: name, + Photo: &searchmsg.Photo{ + TakenDateTime: &typespb.Timestamp{Seconds: taken}, + }, + }, + } + } + ) + + BeforeEach(func() { + gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*sprovider.StorageSpace{spaceA, spaceB}, + }, nil) + indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool { + return req.Ref != nil && req.Ref.ResourceId.SpaceId == "a" + })).Return(&searchsvc.SearchIndexResponse{ + TotalMatches: 2, + Matches: []*searchmsg.Match{ + photoMatch("a", "a-old.jpg", 100, 0.9), + photoMatch("a", "a-new.jpg", 300, 0.1), + }, + }, nil) + indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool { + return req.Ref != nil && req.Ref.ResourceId.SpaceId == "b" + })).Return(&searchsvc.SearchIndexResponse{ + TotalMatches: 2, + Matches: []*searchmsg.Match{ + photoMatch("b", "b-newest.jpg", 400, 0.5), + photoMatch("b", "b-mid.jpg", 200, 0.4), + }, + }, nil) + }) + + It("forwards order_by to the engine", func() { + _, err := s.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}}, + }) + Expect(err).ToNot(HaveOccurred()) + indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool { + return len(req.OrderBy) == 1 && + req.OrderBy[0].Name == "photo.takenDateTime" && + req.OrderBy[0].IsDescending + })) + }) + + It("merges matches across spaces in sort order", func() { + res, err := s.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}}, + }) + Expect(err).ToNot(HaveOccurred()) + names := []string{} + for _, m := range res.Matches { + names = append(names, m.Entity.Name) + } + Expect(names).To(Equal([]string{"b-newest.jpg", "a-new.jpg", "b-mid.jpg", "a-old.jpg"})) + }) + + It("merges matches by score when no order_by is given", func() { + res, err := s.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + }) + Expect(err).ToNot(HaveOccurred()) + names := []string{} + for _, m := range res.Matches { + names = append(names, m.Entity.Name) + } + Expect(names).To(Equal([]string{"a-old.jpg", "b-newest.jpg", "b-mid.jpg", "a-new.jpg"})) + }) + }) + Context("with two personal spaces returning aggregations", func() { var ( spaceA = &sprovider.StorageSpace{ diff --git a/services/search/pkg/search/sort.go b/services/search/pkg/search/sort.go new file mode 100644 index 0000000000..af61fd99d7 --- /dev/null +++ b/services/search/pkg/search/sort.go @@ -0,0 +1,257 @@ +package search + +import ( + "reflect" + "strings" + + "google.golang.org/protobuf/reflect/protoreflect" + + searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" + searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" +) + +// Sorting support for search results (graph sortProperties / proto order_by). +// +// A field is sortable when both of the following hold: +// - it is indexed as a scalar (string, number, bool or time), so the +// engines can sort on it natively; multivalued fields like Tags are not +// sortable +// - it is carried on the match entity, so the service layer can read the +// sort key when merging the per-space result streams +// +// Both sets are derived by reflection (index side: the Resource type, match +// side: the Entity proto), so new facet fields become sortable automatically. + +// sortIndexAliases maps the graph-facing names of top-level fields to their +// index field names. Facet fields (photo.*, audio.*, ...) share the same +// dotted names in both worlds and need no alias. Top-level fields are only +// exposed under these graph names; internal fields like RootID or Deleted +// stay unsortable. +var sortIndexAliases = map[string]string{ + "name": "Name", + "size": "Size", + "lastModifiedDateTime": "Mtime", + "mimeType": "MimeType", +} + +// entityJSONAliases maps graph-facing names to the Entity proto's JSON names +// where the two disagree. +var entityJSONAliases = map[string]string{ + "lastModifiedDateTime": "lastModifiedTime", +} + +// IsSortableField reports whether results can be sorted by the field. +func IsSortableField(name string) bool { + _, ok := SortIndexField(name) + return ok +} + +// SortIndexField translates a graph sortProperties name into the index field +// name to sort on, reporting whether the field is sortable at all. +func SortIndexField(name string) (string, bool) { + field := name + if alias, ok := sortIndexAliases[name]; ok { + field = alias + } else if !strings.Contains(name, ".") { + return "", false + } + if !sortableIndexFields[field] { + return "", false + } + if !entityFieldResolvable(name) { + return "", false + } + return field, true +} + +// CompareMatches orders match a relative to b according to orderBy: -1 when a +// comes first, 1 when b comes first, 0 when the sort keys tie (callers fall +// back to the score). Matches missing a sort key sort after those that have +// it, regardless of direction. +func CompareMatches(a, b *searchmsg.Match, orderBy []*searchsvc.SortProperty) int { + for _, sp := range orderBy { + ka := matchSortKey(a, sp.GetName()) + kb := matchSortKey(b, sp.GetName()) + if !ka.present && !kb.present { + continue + } + if !ka.present { + return 1 + } + if !kb.present { + return -1 + } + c := 0 + switch { + case ka.isString: + c = strings.Compare(ka.str, kb.str) + case ka.num < kb.num: + c = -1 + case ka.num > kb.num: + c = 1 + } + if c == 0 { + continue + } + if sp.GetIsDescending() { + c = -c + } + return c + } + return 0 +} + +// sortableIndexFields is the set of scalar indexed fields, keyed by index +// field name. +var sortableIndexFields = buildSortableFieldSet() + +func buildSortableFieldSet() map[string]bool { + out := map[string]bool{} + collectScalarFields(out, "", reflect.TypeOf(Resource{})) + return out +} + +func collectScalarFields(out map[string]bool, prefix string, t reflect.Type) { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + if f.Anonymous { + collectScalarFields(out, prefix, f.Type) + continue + } + path := prefix + jsonFieldName(f) + ft := f.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + switch ft.Kind() { + case reflect.String, reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + out[path] = true + case reflect.Struct: + if ft == timeType { + out[path] = true + continue + } + collectScalarFields(out, path+".", ft) + } + } +} + +func entityPath(name string) []string { + if alias, ok := entityJSONAliases[name]; ok { + name = alias + } + return strings.Split(name, ".") +} + +const timestampFullName = protoreflect.FullName("google.protobuf.Timestamp") + +// entityFieldResolvable reports whether the graph field name resolves to a +// scalar (or timestamp) field on the match entity. +func entityFieldResolvable(name string) bool { + md := (&searchmsg.Entity{}).ProtoReflect().Descriptor() + segments := entityPath(name) + for i, seg := range segments { + fd := md.Fields().ByJSONName(seg) + if fd == nil || fd.IsList() || fd.IsMap() { + return false + } + if i < len(segments)-1 { + if fd.Kind() != protoreflect.MessageKind { + return false + } + md = fd.Message() + continue + } + switch fd.Kind() { + case protoreflect.StringKind, protoreflect.BoolKind, + protoreflect.Int32Kind, protoreflect.Int64Kind, + protoreflect.Sint32Kind, protoreflect.Sint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint32Kind, protoreflect.Uint64Kind, + protoreflect.Fixed32Kind, protoreflect.Fixed64Kind, + protoreflect.FloatKind, protoreflect.DoubleKind: + return true + case protoreflect.MessageKind: + return fd.Message().FullName() == timestampFullName + } + return false + } + return false +} + +// sortKey is the comparable value of a sort field on a concrete match. +type sortKey struct { + present bool + isString bool + str string + num float64 +} + +// matchSortKey extracts the sort key for the graph field name from a match by +// walking the entity proto along the field's JSON names. +func matchSortKey(m *searchmsg.Match, name string) sortKey { + entity := m.GetEntity() + if entity == nil { + return sortKey{} + } + msg := entity.ProtoReflect() + segments := entityPath(name) + for i, seg := range segments { + fd := msg.Descriptor().Fields().ByJSONName(seg) + if fd == nil || fd.IsList() || fd.IsMap() { + return sortKey{} + } + if i < len(segments)-1 { + if fd.Kind() != protoreflect.MessageKind || !msg.Has(fd) { + return sortKey{} + } + msg = msg.Get(fd).Message() + continue + } + if fd.HasPresence() && !msg.Has(fd) { + return sortKey{} + } + v := msg.Get(fd) + switch fd.Kind() { + case protoreflect.StringKind: + return sortKey{present: true, isString: true, str: v.String()} + case protoreflect.BoolKind: + num := 0.0 + if v.Bool() { + num = 1.0 + } + return sortKey{present: true, num: num} + case protoreflect.Int32Kind, protoreflect.Int64Kind, + protoreflect.Sint32Kind, protoreflect.Sint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind: + return sortKey{present: true, num: float64(v.Int())} + case protoreflect.Uint32Kind, protoreflect.Uint64Kind, + protoreflect.Fixed32Kind, protoreflect.Fixed64Kind: + return sortKey{present: true, num: float64(v.Uint())} + case protoreflect.FloatKind, protoreflect.DoubleKind: + return sortKey{present: true, num: v.Float()} + case protoreflect.MessageKind: + if fd.Message().FullName() != timestampFullName { + return sortKey{} + } + ts := v.Message() + seconds := ts.Get(ts.Descriptor().Fields().ByName("seconds")).Int() + nanos := ts.Get(ts.Descriptor().Fields().ByName("nanos")).Int() + return sortKey{present: true, num: float64(seconds) + float64(nanos)/1e9} + } + return sortKey{} + } + return sortKey{} +} diff --git a/services/search/pkg/search/sort_test.go b/services/search/pkg/search/sort_test.go new file mode 100644 index 0000000000..dd294d65e2 --- /dev/null +++ b/services/search/pkg/search/sort_test.go @@ -0,0 +1,125 @@ +package search_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/types/known/timestamppb" + + searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" + searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" + "github.com/opencloud-eu/opencloud/services/search/pkg/search" +) + +var _ = Describe("SortIndexField", func() { + DescribeTable("maps graph field names to index fields", + func(name string, wantField string, wantOK bool) { + field, ok := search.SortIndexField(name) + Expect(ok).To(Equal(wantOK)) + Expect(field).To(Equal(wantField)) + }, + // top-level fields are exposed under their graph names + Entry("name", "name", "Name", true), + Entry("size", "size", "Size", true), + Entry("lastModifiedDateTime", "lastModifiedDateTime", "Mtime", true), + Entry("mimeType", "mimeType", "MimeType", true), + // facet fields keep their dotted names + Entry("photo.takenDateTime", "photo.takenDateTime", "photo.takenDateTime", true), + Entry("photo.iso", "photo.iso", "photo.iso", true), + Entry("photo.cameraModel", "photo.cameraModel", "photo.cameraModel", true), + Entry("audio.artist", "audio.artist", "audio.artist", true), + Entry("audio.year", "audio.year", "audio.year", true), + Entry("image.width", "image.width", "image.width", true), + Entry("location.latitude", "location.latitude", "location.latitude", true), + // bare facets (message-typed, no scalar value) are not sortable + Entry("audio (bare facet)", "audio", "", false), + Entry("photo (bare facet)", "photo", "", false), + Entry("location (bare facet)", "location", "", false), + Entry("location (dotted but not scalar)", "location.", "", false), + // multivalued fields are not sortable + Entry("tags (repeated)", "tags", "", false), + Entry("Tags (index name, repeated)", "Tags", "", false), + // internal index fields are not exposed under their index names + Entry("Name (index name)", "Name", "", false), + Entry("Mtime (index name)", "Mtime", "", false), + Entry("RootID", "RootID", "", false), + Entry("Deleted", "Deleted", "", false), + // unknown fields + Entry("unknown", "definitelyNotAField", "", false), + Entry("unknown facet field", "photo.definitelyNotAField", "", false), + Entry("empty", "", "", false), + ) +}) + +var _ = Describe("CompareMatches", func() { + match := func(mutate func(e *searchmsg.Entity)) *searchmsg.Match { + e := &searchmsg.Entity{} + mutate(e) + return &searchmsg.Match{Entity: e} + } + asc := func(name string) []*searchsvc.SortProperty { + return []*searchsvc.SortProperty{{Name: name}} + } + desc := func(name string) []*searchsvc.SortProperty { + return []*searchsvc.SortProperty{{Name: name, IsDescending: true}} + } + + It("compares string fields lexicographically", func() { + a := match(func(e *searchmsg.Entity) { e.Name = "a.jpg" }) + b := match(func(e *searchmsg.Entity) { e.Name = "b.jpg" }) + Expect(search.CompareMatches(a, b, asc("name"))).To(Equal(-1)) + Expect(search.CompareMatches(b, a, asc("name"))).To(Equal(1)) + Expect(search.CompareMatches(a, b, desc("name"))).To(Equal(1)) + }) + + It("compares numeric fields numerically", func() { + small := match(func(e *searchmsg.Entity) { e.Size = 9 }) + big := match(func(e *searchmsg.Entity) { e.Size = 10 }) + Expect(search.CompareMatches(small, big, asc("size"))).To(Equal(-1)) + Expect(search.CompareMatches(small, big, desc("size"))).To(Equal(1)) + }) + + It("compares timestamps", func() { + older := match(func(e *searchmsg.Entity) { + e.Photo = &searchmsg.Photo{TakenDateTime: ×tamppb.Timestamp{Seconds: 100}} + }) + newer := match(func(e *searchmsg.Entity) { + e.Photo = &searchmsg.Photo{TakenDateTime: ×tamppb.Timestamp{Seconds: 200}} + }) + Expect(search.CompareMatches(older, newer, asc("photo.takenDateTime"))).To(Equal(-1)) + Expect(search.CompareMatches(older, newer, desc("photo.takenDateTime"))).To(Equal(1)) + }) + + It("compares lastModifiedDateTime via the entity's lastModifiedTime", func() { + older := match(func(e *searchmsg.Entity) { + e.LastModifiedTime = ×tamppb.Timestamp{Seconds: 100} + }) + newer := match(func(e *searchmsg.Entity) { + e.LastModifiedTime = ×tamppb.Timestamp{Seconds: 200} + }) + Expect(search.CompareMatches(older, newer, asc("lastModifiedDateTime"))).To(Equal(-1)) + }) + + It("sorts matches missing the field after those that have it, in both directions", func() { + has := match(func(e *searchmsg.Entity) { + e.Photo = &searchmsg.Photo{TakenDateTime: ×tamppb.Timestamp{Seconds: 100}} + }) + missing := match(func(e *searchmsg.Entity) {}) + Expect(search.CompareMatches(has, missing, asc("photo.takenDateTime"))).To(Equal(-1)) + Expect(search.CompareMatches(missing, has, asc("photo.takenDateTime"))).To(Equal(1)) + Expect(search.CompareMatches(has, missing, desc("photo.takenDateTime"))).To(Equal(-1)) + }) + + It("falls through to the next sort property on ties", func() { + a := match(func(e *searchmsg.Entity) { e.Size = 5; e.Name = "a" }) + b := match(func(e *searchmsg.Entity) { e.Size = 5; e.Name = "b" }) + orderBy := []*searchsvc.SortProperty{{Name: "size"}, {Name: "name"}} + Expect(search.CompareMatches(a, b, orderBy)).To(Equal(-1)) + }) + + It("returns 0 for full ties and empty orderBy", func() { + a := match(func(e *searchmsg.Entity) { e.Size = 5 }) + b := match(func(e *searchmsg.Entity) { e.Size = 5 }) + Expect(search.CompareMatches(a, b, asc("size"))).To(Equal(0)) + Expect(search.CompareMatches(a, b, nil)).To(Equal(0)) + }) +}) diff --git a/services/search/pkg/service/grpc/v0/service.go b/services/search/pkg/service/grpc/v0/service.go index 72f02ffa95..2e49e1551f 100644 --- a/services/search/pkg/service/grpc/v0/service.go +++ b/services/search/pkg/service/grpc/v0/service.go @@ -95,7 +95,7 @@ func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *s } ctx = revactx.ContextSetUser(ctx, u) - key := cacheKey(in.Query, in.PageSize, in.Ref, u, in.Aggregations) + key := cacheKey(in.Query, in.PageSize, in.Ref, u, in.Aggregations, in.OrderBy) res, ok := s.FromCache(key) if !ok { var err error @@ -104,6 +104,7 @@ func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *s PageSize: in.PageSize, Ref: in.Ref, Aggregations: in.Aggregations, + OrderBy: in.OrderBy, }) if err != nil { switch err.(type) { @@ -246,17 +247,17 @@ func (s Service) Cache(key string, res *searchsvc.SearchResponse) { } // cacheKey builds the cache identity for a search. Every result-affecting field -// must be in the key, including aggregations (serialised via deterministic proto -// marshalling). If the aggregation proto ever gains a map field, determinism -// requires all writers to set Deterministic=true. -func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User, aggs []*searchsvc.AggregationOption) string { - aggPart := "" - if len(aggs) > 0 { - b, _ := proto.MarshalOptions{Deterministic: true}.Marshal(&searchsvc.SearchRequest{Aggregations: aggs}) - aggPart = string(b) +// must be in the key, including aggregations and order_by (serialised via +// deterministic proto marshalling). If those protos ever gain a map field, +// determinism requires all writers to set Deterministic=true. +func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User, aggs []*searchsvc.AggregationOption, orderBy []*searchsvc.SortProperty) string { + protoPart := "" + if len(aggs) > 0 || len(orderBy) > 0 { + b, _ := proto.MarshalOptions{Deterministic: true}.Marshal(&searchsvc.SearchRequest{Aggregations: aggs, OrderBy: orderBy}) + protoPart = string(b) } return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s|%s", query, pagesize, ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(), - ref.GetPath(), user.GetId().GetOpaqueId(), aggPart) + ref.GetPath(), user.GetId().GetOpaqueId(), protoPart) } diff --git a/services/search/pkg/service/grpc/v0/service_test.go b/services/search/pkg/service/grpc/v0/service_test.go new file mode 100644 index 0000000000..7116bf6677 --- /dev/null +++ b/services/search/pkg/service/grpc/v0/service_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "context" + "testing" + "time" + + user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/jellydator/ttlcache/v2" + "github.com/opencloud-eu/reva/v2/pkg/auth/scope" + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go-micro.dev/v4/metadata" + + "github.com/opencloud-eu/opencloud/pkg/log" + searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" + searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" + searchmocks "github.com/opencloud-eu/opencloud/services/search/pkg/search/mocks" +) + +func newTestService(t *testing.T, searcher *searchmocks.Searcher) (Service, context.Context) { + t.Helper() + + tm, err := jwt.New(map[string]interface{}{"secret": "test-secret"}) + require.NoError(t, err) + + u := &user.User{Id: &user.UserId{OpaqueId: "test-user", Idp: "idp"}, Username: "test"} + scopes, err := scope.AddOwnerScope(nil) + require.NoError(t, err) + tok, err := tm.MintToken(context.Background(), u, scopes) + require.NoError(t, err) + ctx := metadata.Set(context.Background(), revactx.TokenHeader, tok) + + cache := ttlcache.NewCache() + require.NoError(t, cache.SetTTL(30*time.Second)) + + logger := log.NopLogger() + return Service{ + log: &logger, + searcher: searcher, + cache: cache, + tokenManager: tm, + }, ctx +} + +func TestServiceSearchForwardsOrderBy(t *testing.T) { + searcher := searchmocks.NewSearcher(t) + svc, ctx := newTestService(t, searcher) + + var captured *searchsvc.SearchRequest + searcher.EXPECT(). + Search(mock.Anything, mock.Anything). + Run(func(_ context.Context, req *searchsvc.SearchRequest) { + captured = req + }). + Return(&searchsvc.SearchResponse{}, nil). + Once() + + err := svc.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{ + {Name: "photo.takenDateTime", IsDescending: true}, + }, + }, &searchsvc.SearchResponse{}) + require.NoError(t, err) + require.NotNil(t, captured) + require.Len(t, captured.OrderBy, 1) + require.Equal(t, "photo.takenDateTime", captured.OrderBy[0].Name) + require.True(t, captured.OrderBy[0].IsDescending) +} + +func TestServiceSearchCacheDistinguishesOrderBy(t *testing.T) { + searcher := searchmocks.NewSearcher(t) + svc, ctx := newTestService(t, searcher) + + responseFor := func(name string) *searchsvc.SearchResponse { + return &searchsvc.SearchResponse{ + TotalMatches: 1, + Matches: []*searchmsg.Match{{Entity: &searchmsg.Entity{Name: name}}}, + } + } + searcher.EXPECT(). + Search(mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchRequest) bool { + return len(req.OrderBy) > 0 && req.OrderBy[0].IsDescending + })). + Return(responseFor("newest.jpg"), nil). + Once() + searcher.EXPECT(). + Search(mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchRequest) bool { + return len(req.OrderBy) > 0 && !req.OrderBy[0].IsDescending + })). + Return(responseFor("oldest.jpg"), nil). + Once() + + descOut := &searchsvc.SearchResponse{} + require.NoError(t, svc.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime", IsDescending: true}}, + }, descOut)) + + ascOut := &searchsvc.SearchResponse{} + require.NoError(t, svc.Search(ctx, &searchsvc.SearchRequest{ + Query: "mediatype:image", + OrderBy: []*searchsvc.SortProperty{{Name: "photo.takenDateTime"}}, + }, ascOut)) + + require.Equal(t, "newest.jpg", descOut.Matches[0].Entity.Name) + require.Equal(t, "oldest.jpg", ascOut.Matches[0].Entity.Name) +} diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go index 9fdeca628a..46650451f2 100644 --- a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_search_request.go @@ -32,6 +32,8 @@ type SearchRequest struct { Aggregations []AggregationOption `json:"aggregations,omitempty"` // Contains one or more filters to narrow search results to specific buckets of a prior aggregation. Build each filter from the response of a prior search that aggregated on the same field: take the `aggregationFilterToken` of the wanted `searchBucket` and combine it with the field as `{field}:{aggregationFilterToken}`, e.g. `audio.artist:\"ǂǂ50696e6b20466c6f7964\"` for a terms bucket or `audio.year:range(1980, 1990)` for a range bucket. Several buckets of the same field are combined with `{field}:or({aggregationFilterToken},{aggregationFilterToken})`. Whitespace after the commas of `range(...)` and `or(...)` is optional. Multiple filters can be provided as separate array items. This results in a logical AND between the filters. Filters that are not built from server-issued tokens are rejected with `invalidRequest`. AggregationFilters []string `json:"aggregationFilters,omitempty"` + // Contains the ordered collection of fields to sort the results on. If absent, the results are sorted by relevance. See `SortProperty.Name` for the set of sortable fields. Optional. + SortProperties []SortProperty `json:"sortProperties,omitempty"` } type _SearchRequest SearchRequest diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go new file mode 100644 index 0000000000..ff7b63e42a --- /dev/null +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sort_property.go @@ -0,0 +1,196 @@ +/* +Libre Graph API + +Libre Graph is a free API for cloud collaboration inspired by the MS Graph API. + +API version: v1.0.8 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package libregraph + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the SortProperty type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &SortProperty{} + +// SortProperty Indicates the order to sort search results in. Follows the [MS Graph sortProperty](https://learn.microsoft.com/en-us/graph/api/resources/sortproperty) resource type. +type SortProperty struct { + // The name of the property to sort the search results by. Sortable are the scalar properties carried on the search hit's resource: `name`, `size`, `lastModifiedDateTime`, `mimeType` and the scalar facet properties such as `photo.takenDateTime`, `photo.iso`, `audio.artist`, `audio.year` or `image.width`. Strings sort lexicographically, numbers and dates by value. Multivalued properties (e.g. `tags`) and unknown properties are rejected with `invalidRequest`. Required. + Name string `json:"name"` + // Set to `true` to sort the results in descending order. Optional, defaults to `false` (ascending). + IsDescending *bool `json:"isDescending,omitempty"` +} + +type _SortProperty SortProperty + +// NewSortProperty instantiates a new SortProperty object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewSortProperty(name string) *SortProperty { + this := SortProperty{} + this.Name = name + var isDescending bool = false + this.IsDescending = &isDescending + return &this +} + +// NewSortPropertyWithDefaults instantiates a new SortProperty object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewSortPropertyWithDefaults() *SortProperty { + this := SortProperty{} + var isDescending bool = false + this.IsDescending = &isDescending + return &this +} + +// GetName returns the Name field value +func (o *SortProperty) GetName() string { + if o == nil { + var ret string + return ret + } + + return o.Name +} + +// GetNameOk returns a tuple with the Name field value +// and a boolean to check if the value has been set. +func (o *SortProperty) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Name, true +} + +// SetName sets field value +func (o *SortProperty) SetName(v string) { + o.Name = v +} + +// GetIsDescending returns the IsDescending field value if set, zero value otherwise. +func (o *SortProperty) GetIsDescending() bool { + if o == nil || IsNil(o.IsDescending) { + var ret bool + return ret + } + return *o.IsDescending +} + +// GetIsDescendingOk returns a tuple with the IsDescending field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SortProperty) GetIsDescendingOk() (*bool, bool) { + if o == nil || IsNil(o.IsDescending) { + return nil, false + } + return o.IsDescending, true +} + +// HasIsDescending returns a boolean if a field has been set. +func (o *SortProperty) HasIsDescending() bool { + if o != nil && !IsNil(o.IsDescending) { + return true + } + + return false +} + +// SetIsDescending gets a reference to the given bool and assigns it to the IsDescending field. +func (o *SortProperty) SetIsDescending(v bool) { + o.IsDescending = &v +} + +func (o SortProperty) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o SortProperty) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["name"] = o.Name + if !IsNil(o.IsDescending) { + toSerialize["isDescending"] = o.IsDescending + } + return toSerialize, nil +} + +func (o *SortProperty) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "name", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varSortProperty := _SortProperty{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + err = decoder.Decode(&varSortProperty) + + if err != nil { + return err + } + + *o = SortProperty(varSortProperty) + + return err +} + +type NullableSortProperty struct { + value *SortProperty + isSet bool +} + +func (v NullableSortProperty) Get() *SortProperty { + return v.value +} + +func (v *NullableSortProperty) Set(val *SortProperty) { + v.value = val + v.isSet = true +} + +func (v NullableSortProperty) IsSet() bool { + return v.isSet +} + +func (v *NullableSortProperty) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSortProperty(val *SortProperty) *NullableSortProperty { + return &NullableSortProperty{value: val, isSet: true} +} + +func (v *NullableSortProperty) UnmarshalJSON(src []byte) error { + return json.Unmarshal(src, &v.value) +} + +func (v NullableSortProperty) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +}