Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
626 changes: 370 additions & 256 deletions protogen/gen/opencloud/services/search/v0/search.pb.go

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions protogen/gen/opencloud/services/search/v0/search.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
},
Expand Down Expand Up @@ -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."
}
}
},
Expand Down Expand Up @@ -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": {
Expand Down
21 changes: 21 additions & 0 deletions protogen/proto/opencloud/services/search/v0/search.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions services/graph/pkg/service/v0/searchquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions services/graph/pkg/service/v0/searchquery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions services/search/pkg/bleve/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bleve

import (
"context"
"fmt"
"math"
"time"

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions services/search/pkg/opensearch/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -147,6 +167,7 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
},
},
Aggs: builtAggs,
Sort: sortClause,
},
)
if err != nil {
Expand Down
1 change: 0 additions & 1 deletion services/search/pkg/opensearch/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions services/search/pkg/opensearch/internal/osu/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

//----------------------------------------------------------------------------//
Expand Down
10 changes: 0 additions & 10 deletions services/search/pkg/search/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 23 additions & 1 deletion services/search/pkg/search/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading