Some checks failed
Coverage / coverage (push) Failing after 6s
Tests / test (1.24.x, ubuntu-latest) (push) Failing after 42s
Tests / test (1.26.x, ubuntu-latest) (push) Failing after 38s
Tests / test (1.25.x, ubuntu-latest) (push) Failing after 35s
Tests / test (1.25.x, windows-latest) (push) Has been cancelled
Tests / test (1.26.x, macos-latest) (push) Has been cancelled
Tests / test (1.26.x, windows-latest) (push) Has been cancelled
Tests / test (1.24.x, macos-latest) (push) Has been cancelled
Tests / test (1.24.x, windows-latest) (push) Has been cancelled
Tests / test (1.25.x, macos-latest) (push) Has been cancelled
185 lines
5.6 KiB
Go
185 lines
5.6 KiB
Go
// Copyright (c) 2026 Couchbase, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package bleve
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/blevesearch/bleve/v2/mapping"
|
|
"github.com/blevesearch/bleve/v2/search/query"
|
|
)
|
|
|
|
// buildEarlyStopIndex indexes n documents that all match "common", so a bounded
|
|
// scan has plenty of matches left undrained.
|
|
func buildEarlyStopIndex(t *testing.T, n int) Index {
|
|
t.Helper()
|
|
|
|
dir, err := os.MkdirTemp("", "earlystop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
|
|
im := mapping.NewIndexMapping()
|
|
im.DefaultAnalyzer = "standard"
|
|
idx, err := New(dir+"/i.bleve", im)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = idx.Close() })
|
|
|
|
batch := idx.NewBatch()
|
|
for i := 0; i < n; i++ {
|
|
if err := batch.Index(fmt.Sprintf("d%05d", i), map[string]interface{}{
|
|
"body": fmt.Sprintf("common tag%d", i%10),
|
|
"num": float64(i),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := idx.Batch(batch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return idx
|
|
}
|
|
|
|
func earlyStopQuery() query.Query {
|
|
q := query.NewTermQuery("common")
|
|
q.SetField("body")
|
|
return q
|
|
}
|
|
|
|
// TestEarlyStopBoundedScan is the contract for the bounded scan: with
|
|
// Score="none" and a bounded Size, the request means "any Size+From matching
|
|
// docs", so collection may stop once that many hits are in hand instead of
|
|
// draining every match.
|
|
//
|
|
// Two things must hold. The caller must still get Size hits — stopping early must
|
|
// not lose results it asked for. And Total must be reported as a lower bound
|
|
// (TotalRelation "gte"), because the scan genuinely did not count the rest; a
|
|
// caller that reads Total as exact would otherwise be silently misled.
|
|
func TestEarlyStopBoundedScan(t *testing.T) {
|
|
const n = 5000
|
|
idx := buildEarlyStopIndex(t, n)
|
|
|
|
for _, size := range []int{1, 10, 100} {
|
|
req := NewSearchRequest(earlyStopQuery())
|
|
req.Size = size
|
|
req.Score = ScoreNone
|
|
res, err := idx.Search(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != size {
|
|
t.Errorf("size=%d: got %d hits, want %d — early stop dropped results the "+
|
|
"caller asked for", size, len(res.Hits), size)
|
|
}
|
|
if res.TotalRelation != TotalRelationGte {
|
|
t.Errorf("size=%d: TotalRelation=%q, want %q — Total is a lower bound once "+
|
|
"the scan stops early, and saying otherwise misleads the caller",
|
|
size, res.TotalRelation, TotalRelationGte)
|
|
}
|
|
if res.Total > uint64(n) {
|
|
t.Errorf("size=%d: Total=%d exceeds the corpus size %d", size, res.Total, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEarlyStopDoesNotEngageWhenUnsafe pins the preconditions. Each of these
|
|
// requests depends on documents the bounded scan would never look at, so it must
|
|
// keep draining and report an exact Total. Getting any of these wrong is a silent
|
|
// wrong-answer bug, not a slowdown:
|
|
//
|
|
// facets every match must be counted into the facet buckets
|
|
// field sort the top-k by field value can lie anywhere in the match set
|
|
// SearchAfter the cursor position depends on the full ordering
|
|
// scoring Score != "none" means order depends on scores, not arrival
|
|
func TestEarlyStopDoesNotEngageWhenUnsafe(t *testing.T) {
|
|
const n = 2000
|
|
idx := buildEarlyStopIndex(t, n)
|
|
|
|
cases := []struct {
|
|
name string
|
|
tweak func(*SearchRequest)
|
|
}{
|
|
{"facets", func(r *SearchRequest) {
|
|
r.Score = ScoreNone
|
|
r.AddFacet("tags", NewFacetRequest("body", 5))
|
|
}},
|
|
{"field-sort", func(r *SearchRequest) {
|
|
r.Score = ScoreNone
|
|
r.SortBy([]string{"num"})
|
|
}},
|
|
{"search-after", func(r *SearchRequest) {
|
|
r.Score = ScoreNone
|
|
r.SortBy([]string{"_id"})
|
|
r.SearchAfter = []string{"d00010"}
|
|
}},
|
|
{"scoring-enabled", func(r *SearchRequest) {
|
|
// Score defaults to full scoring; ordering depends on scores.
|
|
}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
req := NewSearchRequest(earlyStopQuery())
|
|
req.Size = 10
|
|
tc.tweak(req)
|
|
res, err := idx.Search(req)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", tc.name, err)
|
|
}
|
|
if res.TotalRelation != TotalRelationEq {
|
|
t.Errorf("%s: TotalRelation=%q, want %q — the bounded scan engaged on a "+
|
|
"request whose result depends on documents it would not visit",
|
|
tc.name, res.TotalRelation, TotalRelationEq)
|
|
}
|
|
if res.Total != uint64(n) {
|
|
t.Errorf("%s: Total=%d, want the exact %d", tc.name, res.Total, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEarlyStopHitsAreRealMatches guards the cheapest way for a bounded scan to be
|
|
// wrong: returning documents that do not match. Order is not part of the contract
|
|
// under Score="none", but membership is.
|
|
func TestEarlyStopHitsAreRealMatches(t *testing.T) {
|
|
idx := buildEarlyStopIndex(t, 1000)
|
|
|
|
q := query.NewTermQuery("tag3")
|
|
q.SetField("body")
|
|
req := NewSearchRequest(q)
|
|
req.Size = 20
|
|
req.Score = ScoreNone
|
|
res, err := idx.Search(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != 20 {
|
|
t.Fatalf("got %d hits, want 20", len(res.Hits))
|
|
}
|
|
// tag3 was indexed on every doc where i%10 == 3.
|
|
for _, h := range res.Hits {
|
|
var i int
|
|
if _, err := fmt.Sscanf(h.ID, "d%05d", &i); err != nil {
|
|
t.Fatalf("unexpected id %q: %v", h.ID, err)
|
|
}
|
|
if i%10 != 3 {
|
|
t.Errorf("id %s does not match tag3 — the bounded scan returned a non-match", h.ID)
|
|
}
|
|
}
|
|
}
|