Skip to content

Commit f4cd08f

Browse files
feat: Elasticsearch Serverless support and v9.3.0 release (#389)
* feat: add Elasticsearch Serverless support for meta indices and API key auth Detect serverless clusters via build_flavor and remap dot-prefixed meta indices to rs_* names, since Serverless rejects user-created hidden indices. Add ES_API_KEY upstream auth, strip unsupported index settings on create, and use _cat/indices for index size where _stats is unavailable. Co-authored-by: Cursor <cursoragent@cursor.com> * chore (build): upgrade RS API to v9.3.0 --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0fffe9f commit f4cd08f

63 files changed

Lines changed: 685 additions & 89 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
FROM golang:1.26.0 AS builder
1+
FROM golang:1.26.4 AS builder
22

3-
ARG VERSION=9.2.0
3+
ARG VERSION=9.3.0
44
ENV VERSION="${VERSION}"
55

66
# Default value

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
GC=go build
22

33
BUILD_DIR=build
4-
DEFAULT_VERSION=9.2.0
4+
DEFAULT_VERSION=9.3.0
55
VERSION := $(or $(VERSION),$(DEFAULT_VERSION))
66

77
cmd: build

docs/env-vars.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
Plugins might require certain environment variables to be in order initialize the components it needs for its functioning. Those variables can be declared in any file. The path to that file must be provided via the `--env` flag.
44

5-
**Note:** `ES_CLUSTER_URL` is used by all the plugins that are interacting with elasticsearch. `USERNAME` and `PASSWORD` are temporary entry point master credentials in order to test the plugins.
5+
**Note:** `ES_CLUSTER_URL` is used by all the plugins that are interacting with elasticsearch. Basic auth credentials may be embedded in `ES_CLUSTER_URL` (for example `https://user:pass@host:443`). Alternatively, set `ES_API_KEY` to use API key auth for upstream elasticsearch (for example Elasticsearch Serverless); `ES_CLUSTER_URL` must not contain credentials when `ES_API_KEY` is set. `USERNAME` and `PASSWORD` are temporary entry point master credentials in order to test the plugins.
6+
7+
**Meta index naming:** ReactiveSearch stores its metadata in dot-prefixed indices (e.g. `.pipelines`, `.users`). Elasticsearch Serverless does not allow creating dot-prefixed indices, so when a Serverless cluster is detected (via `build_flavor` from `GET /`), meta indices are automatically created with the `rs_` prefix instead (e.g. `rs_pipelines`). Set `RS_META_INDEX_PREFIX` to force an alternate prefix on any cluster (the prefix must not start with `_`, `-` or `+`). On Serverless, platform-managed index settings (`index.hidden`, shard/replica counts) are also stripped from index creation requests.
68

79
List of specific env vars required by respective plugins are listed below:
810

model/permission/permission.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ func (p *Permission) CanAccessIndex(name string) (bool, error) {
494494
if suggestionsIndex == "" {
495495
suggestionsIndex = ".suggestions"
496496
}
497-
indices = append(indices, suggestionsIndex)
497+
indices = append(indices, util.MetaIndexName(suggestionsIndex))
498498
}
499499
for _, pattern := range indices {
500500
matched, err := util.ValidateIndex(pattern, name)

model/reindex/dao.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,11 +522,78 @@ func GetAliasIndexMap(ctx context.Context) (map[string]string, error) {
522522
return res, nil
523523
}
524524

525+
// GetIndexStoreSize returns the primary store size in bytes for an index or alias.
526+
// It uses _cat/indices, which is supported on Elasticsearch Serverless where
527+
// _stats is unavailable.
528+
func GetIndexStoreSize(ctx context.Context, indexName string) (int64, error) {
529+
index := indexName
530+
aliasesIndexMap, err := GetAliasIndexMap(ctx)
531+
if err != nil {
532+
return 0, err
533+
}
534+
if indexNameFromMap, ok := aliasesIndexMap[indexName]; ok {
535+
index = indexNameFromMap
536+
}
537+
538+
if !util.IsServerless() {
539+
stats, err := util.GetClient7().IndexStats(index).Do(ctx)
540+
if err == nil {
541+
if val, ok := stats.Indices[index]; ok {
542+
return val.Primaries.Store.SizeInBytes, nil
543+
}
544+
}
545+
}
546+
547+
return indexStoreSizeFromCat(ctx, index)
548+
}
549+
550+
func indexStoreSizeFromCat(ctx context.Context, index string) (int64, error) {
551+
v := url.Values{}
552+
v.Set("format", "json")
553+
v.Set("bytes", "b")
554+
555+
requestOptions := es7.PerformRequestOptions{
556+
Method: "GET",
557+
Path: "/_cat/indices/" + index,
558+
Params: v,
559+
}
560+
response, err := util.GetClient7().PerformRequest(ctx, requestOptions)
561+
if err != nil {
562+
return 0, err
563+
}
564+
if response.StatusCode > 300 {
565+
return 0, errors.New(string(response.Body))
566+
}
567+
568+
var indices []AliasedIndices
569+
if err := json.Unmarshal(response.Body, &indices); err != nil {
570+
return 0, err
571+
}
572+
if len(indices) == 0 {
573+
return 0, fmt.Errorf(`index "%s" not found`, index)
574+
}
575+
576+
storeSize := indices[0].PriStoreSize
577+
if storeSize == "" {
578+
storeSize = indices[0].StoreSize
579+
}
580+
if isNumericStoreSize(storeSize) {
581+
return strconv.ParseInt(storeSize, 10, 64)
582+
}
583+
return ParseStoreSize(storeSize)
584+
}
585+
525586
func isTaskCompleted(ctx context.Context, taskID string) (bool, error) {
526587
isCompleted := false
527588
url := util.GetESURL() + "/_tasks/" + taskID
528589

529-
response, err := http.Get(url)
590+
req, err := http.NewRequest(http.MethodGet, url, nil)
591+
if err != nil {
592+
log.Errorln(logTag, " Get task status error", err)
593+
return isCompleted, err
594+
}
595+
util.ApplyESAuth(req)
596+
response, err := util.HTTPClient().Do(req)
530597
if err != nil {
531598
log.Errorln(logTag, " Get task status error", err)
532599
return isCompleted, err

model/reindex/util.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"strconv"
99
"strings"
1010
"sync"
11+
"unicode"
1112

1213
"github.com/appbaseio/reactivesearch-api/middleware/classify"
14+
"github.com/appbaseio/reactivesearch-api/util"
1315
log "github.com/sirupsen/logrus"
1416
)
1517

@@ -70,6 +72,62 @@ var CurrentlyReIndexingProcessMutex = sync.RWMutex{}
7072
// IndexStoreSize to decide whether to use async or sync re-indexing
7173
const IndexStoreSize = int64(100000000)
7274

75+
// ParseStoreSize converts Elasticsearch _cat/indices size strings (e.g. "1.2kb",
76+
// "100mb") or plain byte counts to bytes. A dash or empty value is treated as zero.
77+
func ParseStoreSize(size string) (int64, error) {
78+
size = strings.TrimSpace(strings.ToLower(size))
79+
if size == "" || size == "-" {
80+
return 0, nil
81+
}
82+
83+
multiplier := int64(1)
84+
units := []struct {
85+
suffix string
86+
mult int64
87+
}{
88+
{"pb", 1024 * 1024 * 1024 * 1024 * 1024},
89+
{"tb", 1024 * 1024 * 1024 * 1024},
90+
{"gb", 1024 * 1024 * 1024},
91+
{"mb", 1024 * 1024},
92+
{"kb", 1024},
93+
{"b", 1},
94+
}
95+
for _, unit := range units {
96+
if strings.HasSuffix(size, unit.suffix) {
97+
size = strings.TrimSuffix(size, unit.suffix)
98+
multiplier = unit.mult
99+
break
100+
}
101+
}
102+
103+
size = strings.TrimSpace(size)
104+
if size == "" {
105+
return 0, fmt.Errorf("invalid store size")
106+
}
107+
108+
value, err := strconv.ParseFloat(size, 64)
109+
if err != nil {
110+
return 0, fmt.Errorf("invalid store size %q: %w", size, err)
111+
}
112+
if value < 0 {
113+
return 0, fmt.Errorf("invalid store size %q", size)
114+
}
115+
116+
return int64(value * float64(multiplier)), nil
117+
}
118+
119+
func isNumericStoreSize(size string) bool {
120+
if size == "" {
121+
return false
122+
}
123+
for _, r := range size {
124+
if !unicode.IsDigit(r) {
125+
return false
126+
}
127+
}
128+
return true
129+
}
130+
73131
// reindexedName calculates from the name the number of times an index has been
74132
// reindexed to generate the successive name for the index. For example: for an
75133
// index named "twitter", the funtion returns "twitter_reindexed_1", and for an
@@ -155,7 +213,7 @@ func getSearchRelevancyIndex() string {
155213
if searchRelevancyIndex == "" {
156214
searchRelevancyIndex = ".searchrelevancy"
157215
}
158-
return searchRelevancyIndex
216+
return util.MetaIndexName(searchRelevancyIndex)
159217
}
160218

161219
// Returns the index name for synonyms
@@ -164,5 +222,5 @@ func getSynonymsIndex() string {
164222
if synonymsIndex == "" {
165223
synonymsIndex = ".rs-synonyms"
166224
}
167-
return synonymsIndex
225+
return util.MetaIndexName(synonymsIndex)
168226
}

model/reindex/util_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package reindex
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/smartystreets/goconvey/convey"
7+
)
8+
9+
func TestParseStoreSize(t *testing.T) {
10+
Convey("ParseStoreSize", t, func() {
11+
Convey("should treat empty and dash as zero", func() {
12+
size, err := ParseStoreSize("")
13+
So(err, ShouldBeNil)
14+
So(size, ShouldEqual, 0)
15+
16+
size, err = ParseStoreSize("-")
17+
So(err, ShouldBeNil)
18+
So(size, ShouldEqual, 0)
19+
})
20+
21+
Convey("should parse human-readable sizes", func() {
22+
size, err := ParseStoreSize("1.2kb")
23+
So(err, ShouldBeNil)
24+
So(size, ShouldEqual, int64(1228))
25+
26+
size, err = ParseStoreSize("100mb")
27+
So(err, ShouldBeNil)
28+
So(size, ShouldEqual, int64(100*1024*1024))
29+
30+
size, err = ParseStoreSize("2gb")
31+
So(err, ShouldBeNil)
32+
So(size, ShouldEqual, int64(2*1024*1024*1024))
33+
})
34+
35+
Convey("should parse plain byte counts", func() {
36+
size, err := ParseStoreSize("512")
37+
So(err, ShouldBeNil)
38+
So(size, ShouldEqual, 512)
39+
40+
size, err = ParseStoreSize("100b")
41+
So(err, ShouldBeNil)
42+
So(size, ShouldEqual, 100)
43+
})
44+
45+
Convey("should reject invalid values", func() {
46+
_, err := ParseStoreSize("not-a-size")
47+
So(err, ShouldNotBeNil)
48+
})
49+
})
50+
}
51+
52+
func TestIsNumericStoreSize(t *testing.T) {
53+
Convey("isNumericStoreSize", t, func() {
54+
So(isNumericStoreSize("12345"), ShouldBeTrue)
55+
So(isNumericStoreSize("1.2kb"), ShouldBeFalse)
56+
So(isNumericStoreSize(""), ShouldBeFalse)
57+
})
58+
}

plugins/analytics/analytics.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ func (a *Analytics) InitFunc() error {
132132
recentDocumentsIndex = defaultRecentSearchesEsIndex
133133
}
134134

135+
// resolve cluster-safe meta index names (e.g. serverless)
136+
analyticsIndex = util.MetaIndexName(analyticsIndex)
137+
logsIndex = util.MetaIndexName(logsIndex)
138+
userSessionIndex = util.MetaIndexName(userSessionIndex)
139+
analyticsInsightsIndex = util.MetaIndexName(analyticsInsightsIndex)
140+
usersIndex = util.MetaIndexName(usersIndex)
141+
savedSearchedIndex = util.MetaIndexName(savedSearchedIndex)
142+
favoritesIndex = util.MetaIndexName(favoritesIndex)
143+
preferencesIndex = util.MetaIndexName(preferencesIndex)
144+
recentDocumentsIndex = util.MetaIndexName(recentDocumentsIndex)
145+
135146
// initialize the dao
136147
var err error
137148
a.es, err = initPlugin(analyticsIndex, logsIndex, usersIndex, userSessionIndex, analyticsInsightsIndex, savedSearchedIndex, favoritesIndex, mapping, analyticsMapping,

plugins/analytics/dao.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func initPlugin(analyticsAlias, logsIndex, usersIndex, userSessionIndex, insight
9696
replicas := util.GetReplicas()
9797
// Analytics index does not exists, create a new one
9898
if !isAnalyticsIndexExist {
99-
settings := fmt.Sprintf(analyticsMapping, analyticsAlias, getAnalyticsMappings(), util.HiddenIndexSettings(), replicas)
99+
settings := util.AdaptIndexBody(fmt.Sprintf(analyticsMapping, analyticsAlias, getAnalyticsMappings(), util.HiddenIndexSettings(), replicas))
100100
analyticsIndex := analyticsAlias + `-000001`
101101
_, err = util.GetClient7().CreateIndex(analyticsIndex).
102102
Body(settings).
@@ -142,11 +142,11 @@ func initPlugin(analyticsAlias, logsIndex, usersIndex, userSessionIndex, insight
142142
log.Println(logTag, ": successfully created index named", analyticsAlias)
143143
}
144144

145-
settings := fmt.Sprintf(mapping, util.HiddenIndexSettings(), replicas)
145+
settings := util.AdaptIndexBody(fmt.Sprintf(mapping, util.HiddenIndexSettings(), replicas))
146146

147147
// User session index does not exists, create a new one
148148
if !isUserSessionIndexExist {
149-
settings := fmt.Sprintf(userSessionMapping, getUserSessionMappings(), util.HiddenIndexSettings(), replicas)
149+
settings := util.AdaptIndexBody(fmt.Sprintf(userSessionMapping, getUserSessionMappings(), util.HiddenIndexSettings(), replicas))
150150
_, err = util.GetClient7().CreateIndex(userSessionIndex).Body(settings).Do(ctx)
151151
if err != nil {
152152
return nil, fmt.Errorf("error while creating index named %s: %v", userSessionIndex, err)
@@ -183,7 +183,7 @@ func initPlugin(analyticsAlias, logsIndex, usersIndex, userSessionIndex, insight
183183

184184
// If preferences index doesn't exist, create it.
185185
if !isPreferencesIndexExist {
186-
prefsSettings := fmt.Sprintf(preferencesMapping, util.HiddenIndexSettings(), replicas)
186+
prefsSettings := util.AdaptIndexBody(fmt.Sprintf(preferencesMapping, util.HiddenIndexSettings(), replicas))
187187
_, err = util.GetClient7().CreateIndex(preferencesIndex).Body(prefsSettings).Do(ctx)
188188
if err != nil {
189189
return nil, fmt.Errorf("error while creating index named %s: %v", preferencesIndex, err)
@@ -3043,7 +3043,7 @@ func (es *elasticsearch) rolloverIndexJob(alias string) {
30433043
}
30443044

30453045
json.Unmarshal([]byte(rolloverConfiguration), &rolloverConditions)
3046-
settingsString := fmt.Sprintf(`{%s "index.number_of_shards": 2, "index.number_of_replicas": %d}`, util.HiddenIndexSettings(), util.GetReplicas())
3046+
settingsString := util.AdaptIndexBody(fmt.Sprintf(`{%s "index.number_of_shards": 2, "index.number_of_replicas": %d}`, util.HiddenIndexSettings(), util.GetReplicas()))
30473047
settings := make(map[string]interface{})
30483048
json.Unmarshal([]byte(settingsString), &settings)
30493049
rolloverService, err := es7.NewIndicesRolloverService(util.GetClient7()).
@@ -3787,7 +3787,7 @@ func createRecentSearchesIndex(indexWithSuffix, indexConfig string) (*recentDocu
37873787

37883788
mappings = fmt.Sprintf(mappings, mappingForType)
37893789

3790-
settings := fmt.Sprintf(indexConfig, util.HiddenIndexSettings(), replicas, mappings)
3790+
settings := util.AdaptIndexBody(fmt.Sprintf(indexConfig, util.HiddenIndexSettings(), replicas, mappings))
37913791

37923792
// index does not exists, create a new one
37933793
_, err = util.GetClient7().CreateIndex(indexWithSuffix).Body(settings).Do(context.Background())
@@ -3796,7 +3796,7 @@ func createRecentSearchesIndex(indexWithSuffix, indexConfig string) (*recentDocu
37963796
}
37973797

37983798
// Use fallback method to create the index without the mappings
3799-
fallbackSettings := fmt.Sprintf(indexConfig, util.HiddenIndexSettings(), replicas, "{}")
3799+
fallbackSettings := util.AdaptIndexBody(fmt.Sprintf(indexConfig, util.HiddenIndexSettings(), replicas, "{}"))
38003800
_, fallbackErr := util.GetClient7().CreateIndex(indexWithSuffix).Body(fallbackSettings).Do(context.Background())
38013801
if fallbackErr != nil {
38023802
return nil, fmt.Errorf("error while creating index named %s: %v", indexWithSuffix, err)

plugins/analytics/util.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -915,15 +915,15 @@ func GetAnalyticsIndex() string {
915915
if analyticsIndex == "" {
916916
analyticsIndex = defaultAnalyticsEsIndex
917917
}
918-
return analyticsIndex
918+
return util.MetaIndexName(analyticsIndex)
919919
}
920920

921921
func GetDocumentSuggestionsIndex() string {
922922
recentDocumentsIndex := os.Getenv(envRecentSearchesEsIndex)
923923
if recentDocumentsIndex == "" {
924924
recentDocumentsIndex = defaultRecentSearchesEsIndex
925925
}
926-
return recentDocumentsIndex
926+
return util.MetaIndexName(recentDocumentsIndex)
927927
}
928928

929929
func getQueryTypeByID(id string, request querytranslate.RSQuery) *querytranslate.QueryType {

0 commit comments

Comments
 (0)