From d3cdc11e5479f887436fbf8b06e5601133f52611 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sat, 8 Aug 2026 17:01:33 +0200 Subject: [PATCH 1/2] feat(search): geohash_precision aggregation with opensearch geohash_grid --- .../opencloud/services/search/v0/search.pb.go | 236 ++++++++++-------- .../services/search/v0/search.swagger.json | 5 + .../opencloud/services/search/v0/search.proto | 4 + services/graph/pkg/service/v0/searchquery.go | 3 + services/search/pkg/opensearch/backend.go | 7 +- .../pkg/opensearch/internal/aggs/aggs.go | 47 +++- .../pkg/opensearch/internal/aggs/aggs_test.go | 35 ++- services/search/pkg/query/resolver.go | 21 ++ .../model_aggregation_option.go | 37 +++ 9 files changed, 265 insertions(+), 130 deletions(-) diff --git a/protogen/gen/opencloud/services/search/v0/search.pb.go b/protogen/gen/opencloud/services/search/v0/search.pb.go index 567d02e1de..aeda91b350 100644 --- a/protogen/gen/opencloud/services/search/v0/search.pb.go +++ b/protogen/gen/opencloud/services/search/v0/search.pb.go @@ -417,6 +417,10 @@ type AggregationOption struct { // rather than a bucket aggregation; the corresponding AggregationResult // carries `value` instead of `buckets`. MetricKind MetricKind `protobuf:"varint,5,opt,name=metric_kind,json=metricKind,proto3,enum=opencloud.services.search.v0.MetricKind" json:"metric_kind,omitempty"` + // Optional. When > 0, this is a geohash-grid aggregation over `field` (which + // must resolve to a geo-point field) at the given precision (1-12). Buckets + // carry the geohash cell as key and its doc count. OpenSearch backend only. + GeohashPrecision int32 `protobuf:"varint,6,opt,name=geohash_precision,json=geohashPrecision,proto3" json:"geohash_precision,omitempty"` } func (x *AggregationOption) Reset() { @@ -486,6 +490,13 @@ func (x *AggregationOption) GetMetricKind() MetricKind { return MetricKind_METRIC_KIND_UNSPECIFIED } +func (x *AggregationOption) GetGeohashPrecision() int32 { + if x != nil { + return x.GeohashPrecision + } + return 0 +} + type BucketDefinition struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -971,7 +982,7 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, - 0xd9, 0x02, 0x0a, 0x11, 0x41, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4f, + 0x8c, 0x03, 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, @@ -992,120 +1003,123 @@ var file_opencloud_services_search_v0_search_proto_rawDesc = []byte{ 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, 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, + 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x31, 0x0a, 0x11, 0x67, + 0x65, 0x6f, 0x68, 0x61, 0x73, 0x68, 0x5f, 0x70, 0x72, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x10, 0x67, 0x65, + 0x6f, 0x68, 0x61, 0x73, 0x68, 0x50, 0x72, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 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, 0x6c, 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, 0x22, 0x14, 0x0a, 0x12, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, - 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 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, 0xb1, 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, + 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, 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, 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, 0x96, 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, 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, + 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, 0x6c, 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, 0x22, 0x14, 0x0a, 0x12, 0x49, 0x6e, 0x64, 0x65, + 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 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, 0xb1, 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, 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, + 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, 0x96, 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, 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 ( diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index 97b8259e19..e209e9be24 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -183,6 +183,11 @@ "metricKind": { "$ref": "#/definitions/v0MetricKind", "description": "Optional. When set, this aggregation is a scalar metric over `field`\nrather than a bucket aggregation; the corresponding AggregationResult\ncarries `value` instead of `buckets`." + }, + "geohashPrecision": { + "type": "integer", + "format": "int32", + "description": "Optional. When \u003e 0, this is a geohash-grid aggregation over `field` (which\nmust resolve to a geo-point field) at the given precision (1-12). Buckets\ncarry the geohash cell as key and its doc count. OpenSearch backend only." } } }, diff --git a/protogen/proto/opencloud/services/search/v0/search.proto b/protogen/proto/opencloud/services/search/v0/search.proto index b11433bb16..b3b883819b 100644 --- a/protogen/proto/opencloud/services/search/v0/search.proto +++ b/protogen/proto/opencloud/services/search/v0/search.proto @@ -133,6 +133,10 @@ message AggregationOption { // rather than a bucket aggregation; the corresponding AggregationResult // carries `value` instead of `buckets`. MetricKind metric_kind = 5 [(google.api.field_behavior) = OPTIONAL]; + // Optional. When > 0, this is a geohash-grid aggregation over `field` (which + // must resolve to a geo-point field) at the given precision (1-12). Buckets + // carry the geohash cell as key and its doc count. OpenSearch backend only. + int32 geohash_precision = 6 [(google.api.field_behavior) = OPTIONAL]; } enum MetricKind { diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go index 3c957c771a..82a5b6f9f8 100644 --- a/services/graph/pkg/service/v0/searchquery.go +++ b/services/graph/pkg/service/v0/searchquery.go @@ -188,6 +188,9 @@ func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*search if a.MetricKind != nil { agg.MetricKind = metricKindFromLibregraph(*a.MetricKind) } + if a.GeohashPrecision != nil { + agg.GeohashPrecision = *a.GeohashPrecision + } out = append(out, agg) } return out diff --git a/services/search/pkg/opensearch/backend.go b/services/search/pkg/opensearch/backend.go index 59bc4f6319..880cce590f 100644 --- a/services/search/pkg/opensearch/backend.go +++ b/services/search/pkg/opensearch/backend.go @@ -107,6 +107,11 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ searchParams.Size = conversions.ToPointer(int(sir.PageSize)) } + builtAggs, err := aggs.Build(sir.GetAggregations()) + if err != nil { + return nil, fmt.Errorf("failed to build aggregations: %w", err) + } + req, err := osu.BuildSearchReq(&opensearchgoAPI.SearchReq{ Indices: []string{b.index}, Params: searchParams, @@ -125,7 +130,7 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ }, }, }, - Aggs: aggs.Build(sir.GetAggregations()), + Aggs: builtAggs, }, ) if err != nil { diff --git a/services/search/pkg/opensearch/internal/aggs/aggs.go b/services/search/pkg/opensearch/internal/aggs/aggs.go index 5c3a8f88f3..c3889062e0 100644 --- a/services/search/pkg/opensearch/internal/aggs/aggs.go +++ b/services/search/pkg/opensearch/internal/aggs/aggs.go @@ -9,6 +9,7 @@ import ( "strconv" searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" + "github.com/opencloud-eu/opencloud/services/search/pkg/query" ) // DefaultFacetSize matches the bleve backend: pull a generous bucket count per @@ -18,41 +19,57 @@ const DefaultFacetSize = 1000 // Build translates AggregationOptions into the OpenSearch aggregation DSL // (terms, range, metric, nested). Entries get an index-derived name so repeated // aggs on one field don't collide. -func Build(opts []*searchsvc.AggregationOption) map[string]any { +func Build(opts []*searchsvc.AggregationOption) (map[string]any, error) { return buildLevel(opts, "a") } -func buildLevel(opts []*searchsvc.AggregationOption, prefix string) map[string]any { +func buildLevel(opts []*searchsvc.AggregationOption, prefix string) (map[string]any, error) { if len(opts) == 0 { - return nil + return nil, nil } aggs := map[string]any{} for i, opt := range opts { name := fmt.Sprintf("%s_%d", prefix, i) - if entry := buildOne(opt, name); entry != nil { + entry, err := buildOne(opt, name) + if err != nil { + return nil, err + } + if entry != nil { aggs[name] = entry } } if len(aggs) == 0 { - return nil + return nil, nil } - return aggs + return aggs, nil } -func buildOne(opt *searchsvc.AggregationOption, name string) map[string]any { +func buildOne(opt *searchsvc.AggregationOption, name string) (map[string]any, error) { field := opt.GetField() if mk := opt.GetMetricKind(); mk != searchsvc.MetricKind_METRIC_KIND_UNSPECIFIED { - return buildMetric(field, mk) + return buildMetric(field, mk), nil } var entry map[string]any - if ranges := rangesOf(opt); len(ranges) > 0 { + switch { + case opt.GetGeohashPrecision() > 0: + geoField, ok := query.ResolveGeoField(field) + if !ok { + return nil, fmt.Errorf("geohash aggregation on non-geo field %q", field) + } + entry = map[string]any{ + "geohash_grid": map[string]any{ + "field": geoField, + "precision": int(opt.GetGeohashPrecision()), + }, + } + case len(rangesOf(opt)) > 0: entry = map[string]any{ "range": map[string]any{ "field": field, - "ranges": buildRanges(ranges), + "ranges": buildRanges(rangesOf(opt)), }, } - } else { + default: size := int(opt.GetSize()) if size <= 0 { size = DefaultFacetSize @@ -65,11 +82,15 @@ func buildOne(opt *searchsvc.AggregationOption, name string) map[string]any { } } if subs := opt.GetSubAggregations(); len(subs) > 0 { - if nested := buildLevel(subs, name); nested != nil { + nested, err := buildLevel(subs, name) + if err != nil { + return nil, err + } + if nested != nil { entry["aggs"] = nested } } - return entry + return entry, nil } // buildMetric emits the sum/min/max metric. AVG uses a stats agg to transport diff --git a/services/search/pkg/opensearch/internal/aggs/aggs_test.go b/services/search/pkg/opensearch/internal/aggs/aggs_test.go index a1f080411d..01e2b7d81b 100644 --- a/services/search/pkg/opensearch/internal/aggs/aggs_test.go +++ b/services/search/pkg/opensearch/internal/aggs/aggs_test.go @@ -13,9 +13,10 @@ import ( var _ = Describe("Build", func() { It("builds a terms aggregation", func() { - res := aggs.Build([]*searchsvc.AggregationOption{ + res, err := aggs.Build([]*searchsvc.AggregationOption{ {Field: "audio.artist", Size: 10}, }) + Expect(err).ToNot(HaveOccurred()) Expect(res).ToNot(BeNil()) entry, ok := res["a_0"].(map[string]any) Expect(ok).To(BeTrue()) @@ -26,7 +27,7 @@ var _ = Describe("Build", func() { }) It("builds a range aggregation with open-ended bounds", func() { - res := aggs.Build([]*searchsvc.AggregationOption{{ + res, err := aggs.Build([]*searchsvc.AggregationOption{{ Field: "audio.year", BucketDefinition: &searchsvc.BucketDefinition{ Ranges: []*searchsvc.BucketRange{ @@ -36,6 +37,7 @@ var _ = Describe("Build", func() { }, }, }}) + Expect(err).ToNot(HaveOccurred()) r := res["a_0"].(map[string]any)["range"].(map[string]any) Expect(r["field"]).To(Equal("audio.year")) ranges := r["ranges"].([]map[string]any) @@ -51,9 +53,10 @@ var _ = Describe("Build", func() { DescribeTable("builds single-value metric aggregations", func(kind searchsvc.MetricKind, esKind string) { - res := aggs.Build([]*searchsvc.AggregationOption{ + res, err := aggs.Build([]*searchsvc.AggregationOption{ {Field: "audio.duration", MetricKind: kind}, }) + Expect(err).ToNot(HaveOccurred()) body, ok := res["a_0"].(map[string]any)[esKind].(map[string]any) Expect(ok).To(BeTrue()) Expect(body["field"]).To(Equal("audio.duration")) @@ -64,16 +67,17 @@ var _ = Describe("Build", func() { ) It("uses a stats aggregation for AVG", func() { - res := aggs.Build([]*searchsvc.AggregationOption{ + res, err := aggs.Build([]*searchsvc.AggregationOption{ {Field: "audio.duration", MetricKind: searchsvc.MetricKind_METRIC_KIND_AVG}, }) + Expect(err).ToNot(HaveOccurred()) stats, ok := res["a_0"].(map[string]any)["stats"].(map[string]any) Expect(ok).To(BeTrue()) Expect(stats["field"]).To(Equal("audio.duration")) }) It("nests sub-aggregations under their parent bucket", func() { - res := aggs.Build([]*searchsvc.AggregationOption{{ + res, err := aggs.Build([]*searchsvc.AggregationOption{{ Field: "audio.artist", Size: 5, SubAggregations: []*searchsvc.AggregationOption{{ Field: "audio.album", Size: 7, @@ -82,6 +86,7 @@ var _ = Describe("Build", func() { }, }}, }}) + Expect(err).ToNot(HaveOccurred()) album := res["a_0"].(map[string]any)["aggs"].(map[string]any)["a_0_0"].(map[string]any) albumTerms := album["terms"].(map[string]any) Expect(albumTerms["field"]).To(Equal("audio.album")) @@ -89,6 +94,26 @@ var _ = Describe("Build", func() { metric := album["aggs"].(map[string]any)["a_0_0_0"].(map[string]any) Expect(metric["sum"].(map[string]any)["field"]).To(Equal("audio.duration")) }) + + It("builds a geohash_grid aggregation over the resolved geo-point field", func() { + res, err := aggs.Build([]*searchsvc.AggregationOption{ + {Field: "location", GeohashPrecision: 5}, + }) + Expect(err).ToNot(HaveOccurred()) + grid, ok := res["a_0"].(map[string]any)["geohash_grid"].(map[string]any) + Expect(ok).To(BeTrue()) + // "location" resolves to its indexed geo-point sibling field. + Expect(grid["field"]).To(Equal("location_geopoint")) + Expect(grid["precision"]).To(Equal(5)) + }) + + It("rejects a geohash aggregation on a non-geo field", func() { + _, err := aggs.Build([]*searchsvc.AggregationOption{ + {Field: "name", GeohashPrecision: 5}, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("non-geo field")) + }) }) var _ = Describe("Parse", func() { diff --git a/services/search/pkg/query/resolver.go b/services/search/pkg/query/resolver.go index 5a4127a817..60d71c69eb 100644 --- a/services/search/pkg/query/resolver.go +++ b/services/search/pkg/query/resolver.go @@ -39,3 +39,24 @@ func ResolveField(name string) string { } return name } + +// geopointFields maps a lowercased KQL key to its indexed geopoint sibling field, +// derived from the TypeGeopoint entries in the resource field overrides (e.g. +// "location" -> "location_geopoint", "journey.start" -> "journey.start_geopoint"). +var geopointFields = sync.OnceValue(func() map[string]string { + out := map[string]string{} + for key, opts := range (search.Resource{}).SearchFieldOverrides() { + if opts.Type == mapping.TypeGeopoint { + out[strings.ToLower(key)] = key + mapping.GeopointSuffix + } + } + return out +}) + +// ResolveGeoField maps a KQL key to its indexed geopoint field name. ok is false +// when the key is not a geopoint field, so callers can reject geo predicates on +// non-geo fields. +func ResolveGeoField(name string) (string, bool) { + f, ok := geopointFields()[strings.ToLower(name)] + return f, ok +} diff --git a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_aggregation_option.go b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_aggregation_option.go index b15616c7fa..80bd228185 100644 --- a/vendor/github.com/opencloud-eu/libre-graph-api-go/model_aggregation_option.go +++ b/vendor/github.com/opencloud-eu/libre-graph-api-go/model_aggregation_option.go @@ -30,6 +30,8 @@ type AggregationOption struct { SubAggregations []AggregationOption `json:"subAggregations,omitempty"` // When set, this aggregation is a scalar metric over `field` rather than a bucket aggregation: `size` and `bucketDefinition` are ignored, and the corresponding `searchAggregation` in the response carries a `value` rather than `buckets`. Libregraph extension not present in MS Graph. `avg` is not a simple reducer (averages of averages are not averages) — the backend carries `(sum, count)` internally and emits only `value` on the outermost merge. MetricKind *string `json:"metricKind,omitempty"` + // When greater than 0, this is a geohash-grid aggregation over `field`, which must resolve to a geo-point field (e.g. `location`). Each returned `searchBucket` carries a geohash cell as `key` and its count, suitable for density/heatmap rendering. The value is the geohash length (1-12); higher means finer cells. Libregraph extension not present in MS Graph. OpenSearch backend only. + GeohashPrecision *int32 `json:"geohashPrecision,omitempty"` } type _AggregationOption AggregationOption @@ -204,6 +206,38 @@ func (o *AggregationOption) SetMetricKind(v string) { o.MetricKind = &v } +// GetGeohashPrecision returns the GeohashPrecision field value if set, zero value otherwise. +func (o *AggregationOption) GetGeohashPrecision() int32 { + if o == nil || IsNil(o.GeohashPrecision) { + var ret int32 + return ret + } + return *o.GeohashPrecision +} + +// GetGeohashPrecisionOk returns a tuple with the GeohashPrecision field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *AggregationOption) GetGeohashPrecisionOk() (*int32, bool) { + if o == nil || IsNil(o.GeohashPrecision) { + return nil, false + } + return o.GeohashPrecision, true +} + +// HasGeohashPrecision returns a boolean if a field has been set. +func (o *AggregationOption) HasGeohashPrecision() bool { + if o != nil && !IsNil(o.GeohashPrecision) { + return true + } + + return false +} + +// SetGeohashPrecision gets a reference to the given int32 and assigns it to the GeohashPrecision field. +func (o *AggregationOption) SetGeohashPrecision(v int32) { + o.GeohashPrecision = &v +} + func (o AggregationOption) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -227,6 +261,9 @@ func (o AggregationOption) ToMap() (map[string]interface{}, error) { if !IsNil(o.MetricKind) { toSerialize["metricKind"] = o.MetricKind } + if !IsNil(o.GeohashPrecision) { + toSerialize["geohashPrecision"] = o.GeohashPrecision + } return toSerialize, nil } From 59aacc82eca58df88177fb36e8246d75e5b646fd Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sat, 8 Aug 2026 17:03:14 +0200 Subject: [PATCH 2/2] feat(search): support geohash aggregation on the bleve backend --- .../opencloud/services/search/v0/search.pb.go | 2 +- .../services/search/v0/search.swagger.json | 2 +- .../opencloud/services/search/v0/search.proto | 2 +- services/search/pkg/bleve/backend.go | 20 ++- services/search/pkg/bleve/backend_test.go | 58 ++++++ .../pkg/bleve/testdata/mapping.golden.json | 168 ++++++++++++++++++ services/search/pkg/mapping/bleve.go | 7 + services/search/pkg/mapping/geo.go | 95 +++++++++- services/search/pkg/mapping/geo_test.go | 39 ++++ services/search/pkg/mapping/opensearch.go | 6 + services/search/pkg/mapping/serialize.go | 1 + services/search/pkg/query/resolver.go | 25 +++ 12 files changed, 418 insertions(+), 7 deletions(-) diff --git a/protogen/gen/opencloud/services/search/v0/search.pb.go b/protogen/gen/opencloud/services/search/v0/search.pb.go index aeda91b350..62b29dbc8d 100644 --- a/protogen/gen/opencloud/services/search/v0/search.pb.go +++ b/protogen/gen/opencloud/services/search/v0/search.pb.go @@ -419,7 +419,7 @@ type AggregationOption struct { MetricKind MetricKind `protobuf:"varint,5,opt,name=metric_kind,json=metricKind,proto3,enum=opencloud.services.search.v0.MetricKind" json:"metric_kind,omitempty"` // Optional. When > 0, this is a geohash-grid aggregation over `field` (which // must resolve to a geo-point field) at the given precision (1-12). Buckets - // carry the geohash cell as key and its doc count. OpenSearch backend only. + // carry the geohash cell as key and its doc count. GeohashPrecision int32 `protobuf:"varint,6,opt,name=geohash_precision,json=geohashPrecision,proto3" json:"geohash_precision,omitempty"` } diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index e209e9be24..a7e18d7894 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -187,7 +187,7 @@ "geohashPrecision": { "type": "integer", "format": "int32", - "description": "Optional. When \u003e 0, this is a geohash-grid aggregation over `field` (which\nmust resolve to a geo-point field) at the given precision (1-12). Buckets\ncarry the geohash cell as key and its doc count. OpenSearch backend only." + "description": "Optional. When \u003e 0, this is a geohash-grid aggregation over `field` (which\nmust resolve to a geo-point field) at the given precision (1-12). Buckets\ncarry the geohash cell as key and its doc count." } } }, diff --git a/protogen/proto/opencloud/services/search/v0/search.proto b/protogen/proto/opencloud/services/search/v0/search.proto index b3b883819b..3524ec6c95 100644 --- a/protogen/proto/opencloud/services/search/v0/search.proto +++ b/protogen/proto/opencloud/services/search/v0/search.proto @@ -135,7 +135,7 @@ message AggregationOption { MetricKind metric_kind = 5 [(google.api.field_behavior) = OPTIONAL]; // Optional. When > 0, this is a geohash-grid aggregation over `field` (which // must resolve to a geo-point field) at the given precision (1-12). Buckets - // carry the geohash cell as key and its doc count. OpenSearch backend only. + // carry the geohash cell as key and its doc count. int32 geohash_precision = 6 [(google.api.field_behavior) = OPTIONAL]; } diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index fb8572b8b9..67d63cf156 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" "strconv" "strings" @@ -92,7 +93,11 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques } for _, agg := range sir.GetAggregations() { - bleveReq.AddFacet(agg.GetField(), newBleveFacetRequest(agg)) + fr, err := newBleveFacetRequest(agg) + if err != nil { + return nil, err + } + bleveReq.AddFacet(agg.GetField(), fr) } // Sub-aggregations need the matched hit set, not just count facets: widen @@ -187,18 +192,27 @@ func needsSubAggScan(aggs []*searchService.AggregationOption) bool { // after cross-space merge. const defaultFacetSize = 1000 -func newBleveFacetRequest(agg *searchService.AggregationOption) *bleve.FacetRequest { +func newBleveFacetRequest(agg *searchService.AggregationOption) (*bleve.FacetRequest, error) { size := int(agg.GetSize()) if size <= 0 { size = defaultFacetSize } + // Geohash: terms facet on the precomputed geohash-prefix sibling field of + // the requested precision (bleve has no native geohash-grid aggregation). + if p := int(agg.GetGeohashPrecision()); p > 0 { + field, ok := searchQuery.ResolveGeohashField(agg.GetField(), p) + if !ok { + return nil, fmt.Errorf("geohash aggregation on non-geo field %q", agg.GetField()) + } + return bleve.NewFacetRequest(field, size), nil + } fr := bleve.NewFacetRequest(agg.GetField(), size) for _, r := range aggregationRanges(agg) { minP := parseFloatPtr(r.GetFrom()) maxP := parseFloatPtr(r.GetTo()) fr.AddNumericRange(rangeBucketKey(r), minP, maxP) } - return fr + return fr, nil } func extractBleveAggregations(res *bleve.SearchResult, aggs []*searchService.AggregationOption) []*searchService.AggregationResult { diff --git a/services/search/pkg/bleve/backend_test.go b/services/search/pkg/bleve/backend_test.go index 00abab9b47..25530c116c 100644 --- a/services/search/pkg/bleve/backend_test.go +++ b/services/search/pkg/bleve/backend_test.go @@ -795,6 +795,64 @@ var _ = Describe("Bleve", func() { Expect(fields).To(ConsistOf("audio.artist", "audio.album")) }) + Describe("geohash aggregations", func() { + upsertGeo := func(id, name string, lat, lon float64) { + la, lo := lat, lon + r := search.Resource{ + ID: id, + ParentID: rootResource.ID, + RootID: rootResource.ID, + Path: "./" + name, + Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE), + Document: content.Document{ + Name: name, + MimeType: "image/jpeg", + Location: &libregraph.GeoCoordinates{Latitude: &la, Longitude: &lo}, + }, + } + Expect(eng.Upsert(r.ID, r)).To(Succeed()) + } + + BeforeEach(func() { + upsertGeo("1$2!2001", "a.jpg", 52.5100, 13.3800) // Berlin + upsertGeo("1$2!2002", "b.jpg", 52.5105, 13.3805) // ~60m away -> same geohash-5 cell + upsertGeo("1$2!2003", "c.jpg", 48.1372, 11.5756) // Munich -> different cell + }) + + It("buckets geo points into geohash cells by density", func() { + res := searchWithAggs("mediatype:image", &searchsvc.AggregationOption{ + Field: "location", + GeohashPrecision: 5, + }) + Expect(res.Aggregations).To(HaveLen(1)) + agg := res.Aggregations[0] + Expect(agg.Field).To(Equal("location")) + + counts := []int64{} + for _, b := range agg.Buckets { + Expect(b.Key).To(HaveLen(5), "expected a geohash-5 key, got %q", b.Key) + counts = append(counts, b.Count) + } + // Berlin pair shares a cell (2), Munich alone (1). + Expect(counts).To(ConsistOf(int64(2), int64(1))) + }) + + It("rejects a geohash aggregation on a non-geo field", func() { + rID, err := storagespace.ParseID(rootResource.ID) + Expect(err).ToNot(HaveOccurred()) + _, err = eng.Search(context.Background(), &searchsvc.SearchIndexRequest{ + Query: "mediatype:image", + Ref: &searchmsg.Reference{ResourceId: &searchmsg.ResourceID{ + StorageId: rID.StorageId, SpaceId: rID.SpaceId, OpaqueId: rID.OpaqueId, + }}, + Aggregations: []*searchsvc.AggregationOption{ + {Field: "audio.artist", GeohashPrecision: 5}, + }, + }) + Expect(err).To(HaveOccurred()) + }) + }) + Describe("numeric range aggregations", func() { upsertWithYear := func(id, name string, year int32) { r := search.Resource{ diff --git a/services/search/pkg/bleve/testdata/mapping.golden.json b/services/search/pkg/bleve/testdata/mapping.golden.json index f9c598a05b..694ec5dc82 100644 --- a/services/search/pkg/bleve/testdata/mapping.golden.json +++ b/services/search/pkg/bleve/testdata/mapping.golden.json @@ -507,6 +507,174 @@ } } }, + "location_geohash_1": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_10": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_11": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_12": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_2": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_3": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_4": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_5": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_6": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_7": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_8": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, + "location_geohash_9": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "docvalues": true + } + ] + }, "location_geopoint": { "enabled": true, "dynamic": true, diff --git a/services/search/pkg/mapping/bleve.go b/services/search/pkg/mapping/bleve.go index ba4f2ec540..d05c715989 100644 --- a/services/search/pkg/mapping/bleve.go +++ b/services/search/pkg/mapping/bleve.go @@ -58,6 +58,13 @@ func buildBleveDocMapping(t reflect.Type, overrides map[string]FieldOpts, prefix } doc.AddSubDocumentMapping(fi.Name, subDoc) doc.AddFieldMappingsAt(fi.Name+GeopointSuffix, bleve.NewGeoPointFieldMapping()) + // Geohash prefix siblings power the terms-based geohash aggregation + // (bleve has no native geohash-grid). + for p := 1; p <= MaxGeohashPrecision; p++ { + gh := bleve.NewKeywordFieldMapping() + gh.IncludeInAll = false + doc.AddFieldMappingsAt(GeohashField(fi.Name, p), gh) + } return nil } diff --git a/services/search/pkg/mapping/geo.go b/services/search/pkg/mapping/geo.go index 3d0adf61a7..ba53bed1ca 100644 --- a/services/search/pkg/mapping/geo.go +++ b/services/search/pkg/mapping/geo.go @@ -1,6 +1,9 @@ package mapping -import "strings" +import ( + "strconv" + "strings" +) // GeopointSuffix is appended to a field's name to produce the sibling key // that carries the geo_point / bleve-geopoint representation of the @@ -49,3 +52,93 @@ func addGeopointSibling(m map[string]any, dottedPath string) { } parent[leaf+GeopointSuffix] = map[string]any{"lat": lat, "lon": lon} } + +// GeohashSuffix + a precision produce the sibling keyword field carrying the +// geohash prefix of a geopoint at that precision (e.g. "location_geohash_6"). +// bleve has no native geohash-grid aggregation, so a terms aggregation on the +// field of the requested precision reproduces it. OpenSearch runs geohash_grid +// on the _geopoint field directly and ignores these. +const GeohashSuffix = "_geohash_" + +// MaxGeohashPrecision is the finest geohash length indexed as a sibling field. +const MaxGeohashPrecision = 12 + +// GeohashField returns the sibling field name carrying base's geohash at the +// given precision, e.g. GeohashField("location", 6) == "location_geohash_6". +func GeohashField(base string, precision int) string { + return base + GeohashSuffix + strconv.Itoa(precision) +} + +const geohashBase32 = "0123456789bcdefghjkmnpqrstuvwxyz" + +// encodeGeohash returns the standard geohash of (lat, lon) at the given length, +// matching Lucene/OpenSearch so both backends bucket points into the same cells. +func encodeGeohash(lat, lon float64, precision int) string { + latMin, latMax := -90.0, 90.0 + lonMin, lonMax := -180.0, 180.0 + var b strings.Builder + even := true + bit, ch := 0, 0 + for b.Len() < precision { + if even { + mid := (lonMin + lonMax) / 2 + if lon >= mid { + ch |= 1 << (4 - bit) + lonMin = mid + } else { + lonMax = mid + } + } else { + mid := (latMin + latMax) / 2 + if lat >= mid { + ch |= 1 << (4 - bit) + latMin = mid + } else { + latMax = mid + } + } + even = !even + if bit < 4 { + bit++ + } else { + b.WriteByte(geohashBase32[ch]) + bit, ch = 0, 0 + } + } + return b.String() +} + +// addGeohashSiblings writes, for each geopoint override, the geohash prefix of +// the point at every precision 1..MaxGeohashPrecision under the suffixed keys. +func addGeohashSiblings(m map[string]any, overrides map[string]FieldOpts) { + for key, opts := range overrides { + if opts.Type == TypeGeopoint { + addGeohashSibling(m, key) + } + } +} + +func addGeohashSibling(m map[string]any, dottedPath string) { + parts := strings.Split(dottedPath, ".") + parent := m + for _, p := range parts[:len(parts)-1] { + next, ok := parent[p].(map[string]any) + if !ok { + return + } + parent = next + } + leaf := parts[len(parts)-1] + obj, ok := parent[leaf].(map[string]any) + if !ok { + return + } + lon, hasLon := obj["longitude"].(float64) + lat, hasLat := obj["latitude"].(float64) + if !hasLon || !hasLat { + return + } + for p := 1; p <= MaxGeohashPrecision; p++ { + parent[GeohashField(leaf, p)] = encodeGeohash(lat, lon, p) + } +} diff --git a/services/search/pkg/mapping/geo_test.go b/services/search/pkg/mapping/geo_test.go index c0eac54fef..cb2906aa7f 100644 --- a/services/search/pkg/mapping/geo_test.go +++ b/services/search/pkg/mapping/geo_test.go @@ -141,3 +141,42 @@ var _ = Describe("PrepareForIndex geopoint", func() { Expect(endGp["lon"]).To(Equal(elon)) }) }) + +var _ = Describe("geohash", func() { + // Wikipedia's canonical reference point. + const refLat, refLon = 57.64911, 10.40744 + + It("encodes the canonical geohash vector (matches Lucene/OpenSearch)", func() { + Expect(encodeGeohash(refLat, refLon, 11)).To(Equal("u4pruydqqvj")) + }) + + It("is prefix-consistent across precisions", func() { + full := encodeGeohash(refLat, refLon, 11) + for p := 1; p <= 11; p++ { + Expect(encodeGeohash(refLat, refLon, p)).To(Equal(full[:p]), + "precision %d must be the %d-char prefix", p, p) + } + }) + + It("adds geohash prefix siblings for a geopoint at every precision", func() { + type geoDoc struct { + Location *struct { + Longitude *float64 `json:"longitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + } `json:"location,omitempty"` + } + lon, lat := refLon, refLat + doc := geoDoc{Location: &struct { + Longitude *float64 `json:"longitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + }{Longitude: &lon, Latitude: &lat}} + + m, err := PrepareForIndex(doc, map[string]FieldOpts{"location": {Type: TypeGeopoint}}) + Expect(err).ToNot(HaveOccurred()) + for p := 1; p <= MaxGeohashPrecision; p++ { + Expect(m[GeohashField("location", p)]).To(Equal(encodeGeohash(lat, lon, p)), + "location_geohash_%d", p) + } + Expect(m[GeohashField("location", 5)]).To(Equal("u4pru")) + }) +}) diff --git a/services/search/pkg/mapping/opensearch.go b/services/search/pkg/mapping/opensearch.go index abeeb96d65..181890a1d5 100644 --- a/services/search/pkg/mapping/opensearch.go +++ b/services/search/pkg/mapping/opensearch.go @@ -53,6 +53,12 @@ func buildOpenSearchProperties(t reflect.Type, overrides map[string]FieldOpts, p } props[fi.Name] = map[string]any{"properties": subProps} props[fi.Name+GeopointSuffix] = map[string]any{"type": "geo_point"} + // Mirror the bleve geohash prefix siblings so the document shape + // stays consistent across backends (OpenSearch aggregates via + // geohash_grid on _geopoint and does not query these). + for p := 1; p <= MaxGeohashPrecision; p++ { + props[GeohashField(fi.Name, p)] = map[string]any{"type": "keyword"} + } return nil } diff --git a/services/search/pkg/mapping/serialize.go b/services/search/pkg/mapping/serialize.go index 027d10da71..4def3813b8 100644 --- a/services/search/pkg/mapping/serialize.go +++ b/services/search/pkg/mapping/serialize.go @@ -19,5 +19,6 @@ func PrepareForIndex(v any, overrides map[string]FieldOpts) (map[string]any, err return out, nil } addGeopointSiblings(out, overrides) + addGeohashSiblings(out, overrides) return out, nil } diff --git a/services/search/pkg/query/resolver.go b/services/search/pkg/query/resolver.go index 60d71c69eb..b268a75cdc 100644 --- a/services/search/pkg/query/resolver.go +++ b/services/search/pkg/query/resolver.go @@ -60,3 +60,28 @@ func ResolveGeoField(name string) (string, bool) { f, ok := geopointFields()[strings.ToLower(name)] return f, ok } + +// geopointBaseFields maps a lowercased KQL key to the base field name of every +// geopoint field (e.g. "location" -> "location"), used to derive geohash prefix +// sibling fields. +var geopointBaseFields = sync.OnceValue(func() map[string]string { + out := map[string]string{} + for key, opts := range (search.Resource{}).SearchFieldOverrides() { + if opts.Type == mapping.TypeGeopoint { + out[strings.ToLower(key)] = key + } + } + return out +}) + +// ResolveGeohashField maps a KQL key + geohash precision to the indexed geohash +// prefix sibling field (e.g. "location", 6 -> "location_geohash_6"). ok is false +// when the key is not a geopoint field, so callers can reject geohash +// aggregations on non-geo fields. +func ResolveGeohashField(name string, precision int) (string, bool) { + base, ok := geopointBaseFields()[strings.ToLower(name)] + if !ok { + return "", false + } + return mapping.GeohashField(base, precision), true +}