snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,404 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"encoding/xml"
"fmt"
"io"
"net/url"
"regexp"
"runtime"
"slices"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
const (
MaxLineLength = 10000
URLRegexpRE = `(\b|^)(?:https?:\/\/)?[a-zA-Z0-9-.]+\.[a-z]+(\s|\)?[a-zA-Z0-9\-._~:/?#\[\]@!$&'\(\)*\+,;=]*)(\b|$)`
URLMarkdownLinkRE = `(\[[^\]]+\]\([a-zA-Z0-9\-._~:/?#\[\]@!$&'\(\)*\+,;=]+\))`
EmailRE = `^[^\s"]+@[^\s"]+$`
IndexBasePosts = "posts"
IndexBasePosts_MONTH = IndexBasePosts + "month"
IndexBaseChannels = "channels"
IndexBaseUsers = "users"
IndexBaseFiles = "files"
// At the moment, this number is hardcoded. If needed, we can expose
// this to the config.
BulkFlushInterval = 5 * time.Second
// Size of the largest request to be done, in bytes
BulkFlushBytes = 10 * 1024 * 1024 // 10 MiB
)
type BulkSettings struct {
FlushBytes int
FlushInterval time.Duration
FlushNumReqs int
}
var (
urlRe = regexp.MustCompile(URLRegexpRE)
markdownLinkRe = regexp.MustCompile(URLMarkdownLinkRE)
)
// analysisPluginPrefixes lists the prefixes a managed service can prepend to the component name of
// an analysis plugin. AWS OpenSearch Service reports a plugin installed through associate-package
// as "opensearch-analysis-nori", while the plugins it bundles keep their unprefixed names.
var analysisPluginPrefixes = []string{"", "opensearch-"}
// HasAnalysisPlugin reports whether the named analysis plugin is present in the plugin list
// reported by the cluster, accepting both the bundled and the prefixed component names.
func HasAnalysisPlugin(plugins []string, name string) bool {
for _, prefix := range analysisPluginPrefixes {
if slices.Contains(plugins, prefix+name) {
return true
}
}
return false
}
type ESPost struct {
Id string `json:"id"`
TeamId string `json:"team_id"`
ChannelId string `json:"channel_id"`
UserId string `json:"user_id"`
CreateAt int64 `json:"create_at"`
Message string `json:"message"`
Type string `json:"type"`
Hashtags []string `json:"hashtags"`
Attachments string `json:"attachments"`
URLs []string `json:"urls"`
ChannelType string `json:"channel_type"`
}
type ESFile struct {
Id string `json:"id"`
CreatorId string `json:"creator_id"`
ChannelId string `json:"channel_id"`
PostId string `json:"post_id"`
CreateAt int64 `json:"create_at"`
Content string `json:"content"`
Extension string `json:"extension"`
Name string `json:"name"`
}
type ESChannel struct {
Id string `json:"id"`
Type model.ChannelType `json:"type"`
DeleteAt int64 `json:"delete_at"`
UserIDs []string `json:"user_ids"`
TeamId string `json:"team_id"`
TeamMemberIDs []string `json:"team_member_ids"`
NameSuggest []string `json:"name_suggestions"`
}
type ESUser struct {
Id string `json:"id"`
SuggestionsWithFullname []string `json:"suggestions_with_fullname"`
SuggestionsWithoutFullname []string `json:"suggestions_without_fullname"`
DeleteAt int64 `json:"delete_at"`
Roles []string `json:"roles"`
TeamsIds []string `json:"team_id"`
ChannelsIds []string `json:"channel_id"`
}
func ESPostFromPost(post *model.Post, teamId string, channelType string, mmBlocksEnabled bool) (*ESPost, error) {
p := &model.PostForIndexing{
TeamId: teamId,
ChannelType: channelType,
}
err := post.ShallowCopy(&p.Post)
if err != nil {
return nil, err
}
return ESPostFromPostForIndexing(p, mmBlocksEnabled), nil
}
func ESPostFromPostForIndexing(post *model.PostForIndexing, mmBlocksEnabled bool) *ESPost {
searchPost := ESPost{
Id: post.Id,
TeamId: post.TeamId,
ChannelId: post.ChannelId,
UserId: post.UserId,
CreateAt: post.CreateAt,
Message: post.Message,
Type: post.Type,
Hashtags: strings.Fields(post.Hashtags),
ChannelType: post.ChannelType,
}
// Message stays in its own field; everything else searchable (attachments, mm_blocks, Block Kit blocks,
// Adaptive cards) comes from model.Post.AllStrings (attachment Fallback is omitted), omitting the leading message segment when it is
// the same bytes as post.Message so it is not duplicated in the ES "attachments" text field.
allStrings := post.AllStrings(model.AllStringsOptions{OmitInteractiveBlocks: !mmBlocksEnabled})
allStringsButMessage := allStrings
if len(allStringsButMessage) > 0 && strings.TrimSpace(post.Message) != "" && allStringsButMessage[0] == post.Message {
allStringsButMessage = allStringsButMessage[1:]
}
searchPost.Attachments = strings.Join(allStringsButMessage, " ")
var urlAccum []string
for _, s := range allStrings {
urlAccum = append(urlAccum, extractURLsFromMessage(s)...)
}
if len(urlAccum) > 0 {
searchPost.URLs = model.RemoveDuplicateStringsNonSort(urlAccum)
}
if searchPost.Type == "" {
searchPost.Type = "default"
}
return &searchPost
}
func extractURLsFromMessage(message string) []string {
message = markdownLinkRe.ReplaceAllString(message, "")
urls := urlRe.FindAllString(message, -1)
filteredURLs := make([]string, 0)
for _, u := range urls {
u = strings.TrimSpace(u)
urlToCheck := u
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
urlToCheck = "http://" + u
}
parsedURL, err := url.Parse(urlToCheck)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
continue
}
filteredURLs = append(filteredURLs, u)
}
return filteredURLs
}
func splitFilenameWords(name string) string {
result := name
result = strings.ReplaceAll(result, "-", " ")
result = strings.ReplaceAll(result, ".", " ")
return result
}
func ESFileFromFileInfo(file *model.FileInfo, channelId string) *ESFile {
return &ESFile{
Id: file.Id,
CreatorId: file.CreatorId,
ChannelId: channelId,
PostId: file.PostId,
CreateAt: file.CreateAt,
Content: file.Content,
Extension: file.Extension,
Name: file.Name + " " + splitFilenameWords(file.Name),
}
}
func ESFileFromFileForIndexing(file *model.FileForIndexing) *ESFile {
return &ESFile{
Id: file.Id,
CreatorId: file.CreatorId,
ChannelId: file.ChannelId,
PostId: file.PostId,
CreateAt: file.CreateAt,
Content: file.Content,
Extension: file.Extension,
Name: file.Name + " " + splitFilenameWords(file.Name),
}
}
func ESChannelFromChannel(channel *model.Channel, userIDs, teamMemberIDs []string) *ESChannel {
displayNameInputs := searchengine.GetSuggestionInputsSplitBy(channel.DisplayName, " ")
nameInputs := searchengine.GetSuggestionInputsSplitByMultiple(channel.Name, []string{"-", "_"})
return &ESChannel{
Id: channel.Id,
Type: channel.Type,
DeleteAt: channel.DeleteAt,
UserIDs: userIDs,
TeamId: channel.TeamId,
TeamMemberIDs: teamMemberIDs,
NameSuggest: append(displayNameInputs, nameInputs...),
}
}
func ESUserFromUserAndTeams(user *model.User, teamsIds, channelsIds []string) *ESUser {
usernameSuggestions := searchengine.GetSuggestionInputsSplitByMultiple(user.Username, []string{".", "-", "_"})
fullnameStrings := []string{}
if user.FirstName != "" {
fullnameStrings = append(fullnameStrings, user.FirstName)
}
if user.LastName != "" {
fullnameStrings = append(fullnameStrings, user.LastName)
}
fullnameSuggestions := []string{}
if len(fullnameStrings) > 0 {
fullname := strings.Join(fullnameStrings, " ")
fullnameSuggestions = searchengine.GetSuggestionInputsSplitBy(fullname, " ")
}
nicknameSuggestions := []string{}
if user.Nickname != "" {
nicknameSuggestions = searchengine.GetSuggestionInputsSplitBy(user.Nickname, " ")
}
usernameAndNicknameSuggestions := append(usernameSuggestions, nicknameSuggestions...)
return &ESUser{
Id: user.Id,
SuggestionsWithFullname: append(usernameAndNicknameSuggestions, fullnameSuggestions...),
SuggestionsWithoutFullname: usernameAndNicknameSuggestions,
DeleteAt: user.DeleteAt,
Roles: user.GetRoles(),
TeamsIds: teamsIds,
ChannelsIds: channelsIds,
}
}
func ESUserFromUserForIndexing(userForIndexing *model.UserForIndexing) *ESUser {
user := &model.User{
Id: userForIndexing.Id,
Username: userForIndexing.Username,
Nickname: userForIndexing.Nickname,
FirstName: userForIndexing.FirstName,
Roles: userForIndexing.Roles,
LastName: userForIndexing.LastName,
CreateAt: userForIndexing.CreateAt,
DeleteAt: userForIndexing.DeleteAt,
}
return ESUserFromUserAndTeams(user, userForIndexing.TeamsIds, userForIndexing.ChannelsIds)
}
// SearchIndexName returns the index pattern to search for a given index name.
func SearchIndexName(settings model.ElasticsearchSettings, name string) string {
if *settings.GlobalSearchPrefix == "" {
return *settings.IndexPrefix + name
}
// GlobalSearchPrefix is a prefix of IndexPrefix itself. This is verified in the config.
// Therefore, we use * to search across all indices with the common search prefix.
return *settings.GlobalSearchPrefix + "*" + name
}
func BuildPostIndexName(aggregateAfterDays int, unaggregatedBase string, aggregatedBase string, now time.Time, createAt int64) string {
postTime := time.Unix(createAt/1000, 0)
aggregateCutoffTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, -aggregateAfterDays+1)
if postTime.Before(aggregateCutoffTime) {
return fmt.Sprintf("%v_%d_%02d", aggregatedBase, postTime.Year(), postTime.Month())
}
return fmt.Sprintf("%v_%d_%02d_%02d", unaggregatedBase, postTime.Year(), postTime.Month(), postTime.Day())
}
func NumIndexWorkers() int {
const maxCPU = 4
if runtime.NumCPU() > maxCPU {
return maxCPU
}
return runtime.NumCPU()
}
// maxCertFileSizeBytes is an internal constant
// used to limit file size of ClientCert, ClientKey and CA.
const maxCertFileSizeBytes = 1_000_000 // 1MB
func ReadFileSafely(fb filestore.FileBackend, path string) ([]byte, error) {
rd, err := fb.Reader(path)
if err != nil {
return nil, err
}
defer rd.Close()
type resp struct {
buf []byte
err error
}
ch := make(chan resp)
go func() {
buf, err := io.ReadAll(io.LimitReader(rd, maxCertFileSizeBytes))
ch <- resp{buf, err}
}()
select {
case got := <-ch:
return got.buf, got.err
case <-time.After(10 * time.Second): // Adding a timeout for the file read.
return nil, fmt.Errorf("timed out while reading file: %s", path)
}
}
func GetMatchesForHit(highlights map[string][]string) ([]string, error) {
matchMap := make(map[string]bool)
parseMatches := func(snippets []string) error {
// Highlighted matches are returned as an array of snippets of the post where
// each snippet has the highlighted text surrounded by html <em> tags
for _, snippet := range snippets {
decoder := xml.NewDecoder(strings.NewReader(snippet))
inMatch := false
for {
token, err := decoder.Token()
if err == io.EOF {
break
} else if err != nil {
return err
}
switch typed := token.(type) {
case xml.StartElement:
if typed.Name.Local == "em" {
inMatch = true
}
case xml.EndElement:
if typed.Name.Local == "em" {
inMatch = false
}
case xml.CharData:
if inMatch && len(typed) != 0 {
match := string(typed)
match = strings.Trim(match, "_*~")
matchMap[match] = true
}
}
}
}
return nil
}
if err := parseMatches(highlights["message"]); err != nil {
return nil, err
}
if err := parseMatches(highlights["attachments"]); err != nil {
return nil, err
}
if err := parseMatches(highlights["urls"]); err != nil {
return nil, err
}
if err := parseMatches(highlights["hashtags"]); err != nil {
return nil, err
}
var matches []string
for match := range matchMap {
matches = append(matches, match)
}
return matches, nil
}

View File

@@ -0,0 +1,663 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestElasticsearchBuildPostIndexName(t *testing.T) {
now := time.Date(2017, 8, 14, 15, 16, 17, 123, time.Local)
sixDaysAgo := time.Date(2017, 8, 9, 12, 11, 10, 987, time.Local)
sevenDaysAgo := time.Date(2017, 8, 8, 11, 10, 9, 876, time.Local)
eightDaysAgo := time.Date(2017, 8, 7, 6, 5, 4, 321, time.Local)
sixMillis := sixDaysAgo.UnixNano() / int64(time.Millisecond)
sevenMillis := sevenDaysAgo.UnixNano() / int64(time.Millisecond)
eightMillis := eightDaysAgo.UnixNano() / int64(time.Millisecond)
aggregationCutoff := 7 // Aggregate monthly after 7 days.
sixName := BuildPostIndexName(aggregationCutoff, IndexBasePosts, IndexBasePosts_MONTH, now, sixMillis)
sevenName := BuildPostIndexName(aggregationCutoff, IndexBasePosts, IndexBasePosts_MONTH, now, sevenMillis)
eightName := BuildPostIndexName(aggregationCutoff, IndexBasePosts, IndexBasePosts_MONTH, now, eightMillis)
assert.Equal(t, sixName, "posts_2017_08_09")
assert.Equal(t, sevenName, "posts_2017_08_08")
assert.Equal(t, eightName, "postsmonth_2017_08")
}
func TestHasAnalysisPlugin(t *testing.T) {
installed := []string{"analysis-icu", "analysis-kuromoji", "opensearch-analysis-nori"}
testCases := []struct {
Name string
Plugins []string
Lookup string
Expected bool
}{
{"bundled name", installed, "analysis-kuromoji", true},
{"opensearch prefixed name", installed, "analysis-nori", true},
{"not installed", installed, "analysis-stempel", false},
{"no plugins reported", nil, "analysis-nori", false},
{"unrelated prefix is not accepted", []string{"vendor-analysis-nori"}, "analysis-nori", false},
{"elasticsearch prefix is not accepted", []string{"elasticsearch-analysis-nori"}, "analysis-nori", false},
{"a longer name is not a match", []string{"analysis-noris"}, "analysis-nori", false},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
assert.Equal(t, tc.Expected, HasAnalysisPlugin(tc.Plugins, tc.Lookup))
})
}
}
func TestESPostFromPostForIndexing(t *testing.T) {
t.Run("any form with text only", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
ParentCreateAt: nil,
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "message",
Type: "",
Hashtags: "",
Props: map[string]any{
model.PostPropsAttachments: []any{
map[string]any{
"text": "text 1",
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Equal(t, post.Id, espost.Id)
assert.Equal(t, post.TeamId, espost.TeamId)
assert.Equal(t, post.ChannelId, espost.ChannelId)
assert.Equal(t, post.UserId, espost.UserId)
assert.Equal(t, post.CreateAt, espost.CreateAt)
assert.Equal(t, post.Message, espost.Message)
assert.Equal(t, "default", espost.Type)
assert.Empty(t, espost.Hashtags)
assert.Equal(t, "text 1", espost.Attachments)
})
t.Run("MessageAttachment form with text only", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
ParentCreateAt: nil,
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "message",
Type: "slack_attachment",
Hashtags: "#buh #boh",
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Text: "text 2",
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Equal(t, post.Id, espost.Id)
assert.Equal(t, post.TeamId, espost.TeamId)
assert.Equal(t, post.ChannelId, espost.ChannelId)
assert.Equal(t, post.UserId, espost.UserId)
assert.Equal(t, post.CreateAt, espost.CreateAt)
assert.Equal(t, post.Message, espost.Message)
assert.Equal(t, "slack_attachment", espost.Type)
assert.Len(t, espost.Hashtags, 2)
assert.Equal(t, "text 2", espost.Attachments)
})
t.Run("any form indexes title, pretext, and fields", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []any{
map[string]any{
"title": "Build Failed",
"pretext": "CI notification",
"fallback": "Build Failed on main",
"text": "Details here",
"fields": []any{
map[string]any{
"title": "Branch",
"value": "main",
},
map[string]any{
"title": "Status",
"value": "failed",
},
},
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "Details here")
assert.Contains(t, espost.Attachments, "Build Failed")
assert.Contains(t, espost.Attachments, "CI notification")
assert.NotContains(t, espost.Attachments, "Build Failed on main")
assert.Contains(t, espost.Attachments, "Branch")
assert.Contains(t, espost.Attachments, "main")
assert.Contains(t, espost.Attachments, "Status")
assert.Contains(t, espost.Attachments, "failed")
})
t.Run("MessageAttachment form indexes title, pretext, and fields", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Title: "Build Failed",
Pretext: "CI notification",
Fallback: "Build Failed on main",
Text: "Details here",
Fields: []*model.MessageAttachmentField{
{Title: "Branch", Value: "main"},
{Title: "Status", Value: "failed"},
},
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "Details here")
assert.Contains(t, espost.Attachments, "Build Failed")
assert.Contains(t, espost.Attachments, "CI notification")
assert.NotContains(t, espost.Attachments, "Build Failed on main")
assert.Contains(t, espost.Attachments, "Branch")
assert.Contains(t, espost.Attachments, "main")
assert.Contains(t, espost.Attachments, "Status")
assert.Contains(t, espost.Attachments, "failed")
})
t.Run("empty fields are excluded", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Title: "Only Title",
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Equal(t, "Only Title", espost.Attachments)
})
t.Run("nil fields and attachments are handled", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
nil,
{
Text: "valid",
Fields: []*model.MessageAttachmentField{
nil,
{Title: "field title", Value: "field value"},
},
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "valid")
assert.Contains(t, espost.Attachments, "field title")
assert.Contains(t, espost.Attachments, "field value")
})
t.Run("non-string field values are indexed", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []any{
map[string]any{
"text": "metrics",
"fields": []any{
map[string]any{
"title": "Count",
"value": 42,
},
map[string]any{
"title": "Rate",
"value": 99.5,
},
},
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "metrics")
assert.Contains(t, espost.Attachments, "Count")
assert.Contains(t, espost.Attachments, "42")
assert.Contains(t, espost.Attachments, "Rate")
assert.Contains(t, espost.Attachments, "99.5")
})
t.Run("non-string field values in MessageAttachment form are indexed", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Text: "metrics",
Fields: []*model.MessageAttachmentField{
{Title: "Count", Value: 42},
{Title: "Rate", Value: 99.5},
},
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "metrics")
assert.Contains(t, espost.Attachments, "Count")
assert.Contains(t, espost.Attachments, "42")
assert.Contains(t, espost.Attachments, "Rate")
assert.Contains(t, espost.Attachments, "99.5")
})
t.Run("any form indexes footer and author_name", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []any{
map[string]any{
"text": "body text",
"footer": "Opportunity #OPP-000035341 • United States",
"author_name": "Salesforce",
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "body text")
assert.Contains(t, espost.Attachments, "Opportunity #OPP-000035341 • United States")
assert.Contains(t, espost.Attachments, "Salesforce")
})
t.Run("MessageAttachment form indexes footer and author_name", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Text: "body text",
Footer: "Opportunity #OPP-000035341 • United States",
AuthorName: "Salesforce",
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "body text")
assert.Contains(t, espost.Attachments, "Opportunity #OPP-000035341 • United States")
assert.Contains(t, espost.Attachments, "Salesforce")
})
t.Run("interactive mm_blocks blocks and cards are indexed in attachments field", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "root",
Type: model.PostTypeDefault,
Props: map[string]any{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "mm-line"},
},
model.PostPropsBlockKitBlocks: []any{
map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": "block kit-line",
},
},
},
model.PostPropsAdaptiveCards: []any{
map[string]any{
"type": "AdaptiveCard",
"body": []any{
map[string]any{"type": "TextBlock", "text": "card-line"},
},
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Equal(t, "root", espost.Message)
assert.Contains(t, espost.Attachments, "mm-line")
assert.Contains(t, espost.Attachments, "block kit-line")
assert.Contains(t, espost.Attachments, "card-line")
})
t.Run("interactive content omitted from attachments when mmBlocksEnabled is false", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "root",
Type: model.PostTypeDefault,
Props: map[string]any{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "mm-line"},
},
model.PostPropsBlockKitBlocks: []any{
map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": "block kit-line",
},
},
},
model.PostPropsAdaptiveCards: []any{
map[string]any{
"type": "AdaptiveCard",
"body": []any{
map[string]any{"type": "TextBlock", "text": "card-line"},
},
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, false)
assert.Equal(t, "root", espost.Message)
assert.NotContains(t, espost.Attachments, "mm-line")
assert.NotContains(t, espost.Attachments, "block kit-line")
assert.NotContains(t, espost.Attachments, "card-line")
})
t.Run("multiple attachments are combined", func(t *testing.T) {
post := model.PostForIndexing{
TeamId: model.NewId(),
Post: model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "",
Type: "slack_attachment",
Props: map[string]any{
model.PostPropsAttachments: []any{
map[string]any{
"title": "First",
"text": "one",
},
map[string]any{
"title": "Second",
"text": "two",
},
},
},
},
}
espost := ESPostFromPostForIndexing(&post, true)
assert.Contains(t, espost.Attachments, "First")
assert.Contains(t, espost.Attachments, "one")
assert.Contains(t, espost.Attachments, "Second")
assert.Contains(t, espost.Attachments, "two")
})
}
// TestESPostFromPost_CreatePostJSONRoundTrip simulates the exact flow that happens
// in production: App.CreatePost converts []*MessageAttachment to []any via JSON
// marshal/unmarshal (Post.Attachments in post.go), then the search layer calls
// ESPostFromPost which does ShallowCopy → ESPostFromPostForIndexing.
func TestESPostFromPost_CreatePostJSONRoundTrip(t *testing.T) {
// Step 1: Start with typed MessageAttachment (as a plugin/webhook would create)
original := &model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
UserId: model.NewId(),
CreateAt: model.GetMillis(),
Message: "#closedwon #renewal",
Type: "slack_attachment",
}
original.AddProp(model.PostPropsAttachments, []*model.MessageAttachment{
{
Title: "Account: Acme Corp / Widget Industries, LLC",
Text: "Renewal ARR: $49,140.00",
Pretext: "#closedwon #renewal",
Footer: "Opportunity #OPP-000035341 • United States",
AuthorName: "CRM Bot",
Fields: []*model.MessageAttachmentField{
{Title: "Sales Rep", Value: "Jane Smith"},
{Title: "Account Manager", Value: "John Doe"},
{Title: "Opportunity", Value: "Acme Corp / Widget Industries, LLC - Enterprise - 350 Seats - '26 Renewal"},
{Title: "Seats", Value: 350.0},
},
},
})
// Step 2: Simulate CreatePost JSON round-trip (post.go:305-315)
if attachments, ok := original.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment); ok {
jsonAttachments, err := json.Marshal(attachments)
require.NoError(t, err)
attachmentsInterface := []any{}
err = json.Unmarshal(jsonAttachments, &attachmentsInterface)
require.NoError(t, err)
original.AddProp(model.PostPropsAttachments, attachmentsInterface)
} else {
require.Fail(t, "expected []*MessageAttachment type assertion to succeed")
}
// Step 3: Simulate ESPostFromPost (what the search layer calls)
teamId := model.NewId()
esPost, err := ESPostFromPost(original, teamId, "O", true)
require.NoError(t, err)
// Step 4: Verify all attachment fields are indexed
assert.Contains(t, esPost.Attachments, "Account: Acme Corp / Widget Industries, LLC", "title should be indexed")
assert.Contains(t, esPost.Attachments, "Renewal ARR: $49,140.00", "text should be indexed")
assert.Contains(t, esPost.Attachments, "#closedwon #renewal", "pretext should be indexed")
// Field titles and values
assert.Contains(t, esPost.Attachments, "Sales Rep", "field title should be indexed")
assert.Contains(t, esPost.Attachments, "Jane Smith", "field value should be indexed")
assert.Contains(t, esPost.Attachments, "Account Manager", "field title should be indexed")
assert.Contains(t, esPost.Attachments, "John Doe", "field value should be indexed")
assert.Contains(t, esPost.Attachments, "Opportunity", "field title should be indexed")
assert.Contains(t, esPost.Attachments, "Acme Corp / Widget Industries, LLC - Enterprise - 350 Seats - '26 Renewal", "field value should be indexed")
assert.Contains(t, esPost.Attachments, "350", "numeric field value should be indexed")
// Footer and AuthorName
assert.Contains(t, esPost.Attachments, "Opportunity #OPP-000035341 • United States", "footer should be indexed")
assert.Contains(t, esPost.Attachments, "CRM Bot", "author_name should be indexed")
t.Logf("Attachments field content: %s", esPost.Attachments)
}
// TestESPostFromPost_APIJSONUnmarshal simulates a post created via the REST API
// where Props are deserialized from JSON (attachments arrive as []any directly).
func TestESPostFromPost_APIJSONUnmarshal(t *testing.T) {
// Simulate what happens when a bot POSTs JSON to /api/v4/posts
postJSON := `{
"channel_id": "` + model.NewId() + `",
"message": "",
"props": {
"attachments": [{
"title": "Account: Acme Corp",
"text": "Renewal ARR: $49,140.00",
"footer": "Opportunity #OPP-000035341",
"author_name": "CRM Bot",
"fields": [
{"title": "Sales Rep", "value": "Jane Smith"},
{"title": "Seats", "value": 350}
]
}]
}
}`
var post model.Post
err := json.Unmarshal([]byte(postJSON), &post)
require.NoError(t, err)
post.Id = model.NewId()
post.UserId = model.NewId()
post.CreateAt = model.GetMillis()
// The CreatePost conversion at post.go:305-315 would NOT trigger here
// because the type is already []any, not []*MessageAttachment
_, typeOk := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment)
assert.False(t, typeOk, "API-created post should have []any, not []*MessageAttachment")
esPost, err := ESPostFromPost(&post, model.NewId(), "O", true)
require.NoError(t, err)
assert.Contains(t, esPost.Attachments, "Account: Acme Corp", "title should be indexed")
assert.Contains(t, esPost.Attachments, "Renewal ARR: $49,140.00", "text should be indexed")
assert.Contains(t, esPost.Attachments, "Sales Rep", "field title should be indexed")
assert.Contains(t, esPost.Attachments, "Jane Smith", "field value should be indexed")
assert.Contains(t, esPost.Attachments, "350", "numeric field value should be indexed")
assert.Contains(t, esPost.Attachments, "Opportunity #OPP-000035341", "footer should be indexed")
assert.Contains(t, esPost.Attachments, "CRM Bot", "author_name should be indexed")
t.Logf("Attachments field content: %s", esPost.Attachments)
}
func TestGetMatchesForHit(t *testing.T) {
snippets := map[string][]string{
"message": {
"<em>Apples</em> and oranges and <em>apple</em> and orange",
"Johnny <em>Appleseed</em>",
"That doesn't <em>apply</em> to me, and it doesn't <em>apply</em> to you.",
},
"hashtags": {
"This is an <em>#hashtag</em>",
},
}
expected := []string{
"Apples",
"apple",
"Appleseed",
"apply",
"#hashtag",
}
actual, err := GetMatchesForHit(snippets)
require.NoError(t, err)
require.ElementsMatch(t, expected, actual)
}

View File

@@ -0,0 +1,906 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
const (
timeBetweenBatches = 100 * time.Millisecond
estimatedPostCount = 10000000
estimatedChannelCount = 100000
estimatedFilesCount = 100000
estimatedUserCount = 10000
)
const (
indexOp = "index"
deleteOp = "delete"
)
func NewIndexerWorker(name string,
backend string,
jobServer *jobs.JobServer,
logger mlog.LoggerIFace,
fileBackend filestore.FileBackend,
licenseFn func() *model.License,
createBulkProcessorFn func() error,
addItemToBulkProcessorFn func(indexName string, indexOp string, docID string, body io.ReadSeeker) error,
closeBulkProcessorFn func() (int64, error),
) *IndexerWorker {
return &IndexerWorker{
name: name,
backend: backend,
stoppedCh: make(chan bool, 1),
jobs: make(chan model.Job),
jobServer: jobServer,
logger: logger,
fileBackend: fileBackend,
license: licenseFn,
stopped: true,
createBulkProcessor: createBulkProcessorFn,
addItemToBulkProcessor: addItemToBulkProcessorFn,
closeBulkProcessor: closeBulkProcessorFn,
}
}
type IndexerWorker struct {
name string
backend string
// stateMut protects stopCh and stopped and helps enforce
// ordering in case subsequent Run or Stop calls are made.
stateMut sync.Mutex
stopCh chan struct{}
stopped bool
stoppedCh chan bool
jobs chan model.Job
jobServer *jobs.JobServer
logger mlog.LoggerIFace
fileBackend filestore.FileBackend
license func() *model.License
createBulkProcessor func() error
closeBulkProcessor func() (int64, error)
addItemToBulkProcessor func(indexName, indexOp, docID string, body io.ReadSeeker) error
}
type IndexingProgress struct {
Now time.Time
StartAtTime int64
EndAtTime int64
LastEntityTime int64
TotalPostsCount int64
DonePostsCount int64
DonePosts bool
LastPostID string
TotalFilesCount int64
DoneFilesCount int64
DoneFiles bool
LastFileID string
TotalChannelsCount int64
DoneChannelsCount int64
DoneChannels bool
LastChannelID string
TotalUsersCount int64
DoneUsersCount int64
DoneUsers bool
LastUserID string
}
func (ip *IndexingProgress) CurrentProgress() int64 {
current := ip.DonePostsCount + ip.DoneChannelsCount + ip.DoneUsersCount + ip.DoneFilesCount
total := ip.TotalPostsCount + ip.TotalChannelsCount + ip.TotalFilesCount + ip.TotalUsersCount
if total == 0 {
return 100
}
progress := current * 100 / total
if progress > 100 {
return 100
}
return progress
}
func (ip *IndexingProgress) IsDone(job *model.Job) bool {
// an entity's progress is completed if it was specified not to be indexed, or if it's completed indexing.
donePosts := job.Data["index_posts"] == "false" || ip.DonePosts
doneChannels := job.Data["index_channels"] == "false" || ip.DoneChannels
doneUsers := job.Data["index_users"] == "false" || ip.DoneUsers
doneFiles := job.Data["index_files"] == "false" || ip.DoneFiles
return donePosts && doneChannels && doneUsers && doneFiles
}
func (worker *IndexerWorker) Run() {
worker.stateMut.Lock()
// We have to re-assign the stop channel again, because
// it might happen that the job was restarted due to a config change.
if worker.stopped {
worker.stopped = false
worker.stopCh = make(chan struct{})
} else {
worker.stateMut.Unlock()
return
}
// Run is called from a separate goroutine and doesn't return.
// So we cannot Unlock in a defer clause.
worker.stateMut.Unlock()
worker.logger.Debug("Worker Started")
defer func() {
worker.logger.Debug("Worker: Finished")
worker.stoppedCh <- true
}()
for {
select {
case <-worker.stopCh:
worker.logger.Debug("Worker: Received stop signal")
return
case job := <-worker.jobs:
worker.DoJob(&job)
}
}
}
func (worker *IndexerWorker) Stop() {
worker.stateMut.Lock()
defer worker.stateMut.Unlock()
// Set to close, and if already closed before, then return.
if worker.stopped {
return
}
worker.stopped = true
worker.logger.Debug("Worker Stopping")
close(worker.stopCh)
<-worker.stoppedCh
}
func (worker *IndexerWorker) JobChannel() chan<- model.Job {
return worker.jobs
}
func (worker *IndexerWorker) IsEnabled(cfg *model.Config) bool {
if license := worker.license(); license == nil || !*license.Features.Elasticsearch {
return false
}
if *cfg.ElasticsearchSettings.EnableIndexing {
return true
}
return false
}
func (worker *IndexerWorker) initEntitiesToIndex(job *model.Job) {
// Specifying entities to index is optional, and even when specified, all entities need not be specified.
// This function parses the provided job data and sets enabled or disabled value for each entity,
// so that rest of the code can use job.Data as the source of truth to decide if an entity was
// to be indexed or not.
if job.Data == nil {
job.Data = model.StringMap{}
}
indexPostsRaw, ok := job.Data["index_posts"]
job.Data["index_posts"] = strconv.FormatBool(!ok || indexPostsRaw == "true")
indexChannelsRaw, ok := job.Data["index_channels"]
job.Data["index_channels"] = strconv.FormatBool(!ok || indexChannelsRaw == "true")
indexUsersRaw, ok := job.Data["index_users"]
job.Data["index_users"] = strconv.FormatBool(!ok || indexUsersRaw == "true")
indexFilesRaw, ok := job.Data["index_files"]
job.Data["index_files"] = strconv.FormatBool(!ok || indexFilesRaw == "true")
}
func (worker *IndexerWorker) DoJob(job *model.Job) {
logger := worker.logger.With(jobs.JobLoggerFields(job)...)
logger.Debug("Worker: Received a new candidate job.")
defer worker.jobServer.HandleJobPanic(logger, job)
var appErr *model.AppError
job, appErr = worker.jobServer.ClaimJob(job)
if appErr != nil {
logger.Warn("Worker: Error occurred while trying to claim job", mlog.Err(appErr))
return
} else if job == nil {
return
}
logger.Info("Worker: Indexing job claimed by worker")
err := worker.createBulkProcessor()
if err != nil {
logger.Error("Worker: Failed to setup bulk processor", mlog.Err(err))
appErr := model.NewAppError("IndexerWorker", "ent.elasticsearch.indexer.do_job.create_bulk_processor.error", nil, "", http.StatusInternalServerError).Wrap(err)
if err2 := worker.jobServer.SetJobError(job, appErr); err2 != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err2), mlog.NamedErr("set_error", appErr))
}
return
}
// closeOnce ensures the bulk processor is flushed and closed exactly once
// regardless of which exit path is taken. numFailed is set by the first call
// and read by the success path; logging is handled by closeBulkProcessor itself.
var (
closeOnce sync.Once
numFailed int64
)
doClose := func() {
closeOnce.Do(func() {
numFailed, _ = worker.closeBulkProcessor()
})
}
defer doClose()
worker.initEntitiesToIndex(job)
progress, err := initProgress(logger, worker.jobServer, job, worker.backend)
if err != nil {
return
}
var cancelContext request.CTX = request.EmptyContext(worker.logger)
cancelCtx, cancelCancelWatcher := context.WithCancel(context.Background())
cancelWatcherChan := make(chan struct{}, 1)
cancelContext = cancelContext.WithContext(cancelCtx)
go worker.jobServer.CancellationWatcher(cancelContext, job.Id, cancelWatcherChan)
defer cancelCancelWatcher()
for {
select {
case <-cancelWatcherChan:
logger.Info("Worker: Indexing job has been canceled via CancellationWatcher")
if err := worker.jobServer.SetJobCanceled(job); err != nil {
logger.Error("Worker: Failed to mark job as cancelled", mlog.Err(err))
}
return
case <-worker.stopCh:
logger.Info("Worker: Indexing has been canceled via Worker Stop. Setting the job back to pending.")
if err := worker.jobServer.SetJobPending(job); err != nil {
logger.Error("Worker: Failed to mark job as canceled", mlog.Err(err))
}
return
case <-time.After(timeBetweenBatches):
var err *model.AppError
if progress, err = worker.IndexBatch(logger, progress, job); err != nil {
logger.Error("Worker: Failed to index batch for job", mlog.Err(err))
if err2 := worker.jobServer.SetJobError(job, err); err2 != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err2), mlog.NamedErr("set_error", err))
}
return
}
// Storing the batch progress in metadata.
if job.Data == nil {
job.Data = make(model.StringMap)
}
job.Data["done_posts_count"] = strconv.FormatInt(progress.DonePostsCount, 10)
job.Data["done_channels_count"] = strconv.FormatInt(progress.DoneChannelsCount, 10)
job.Data["done_users_count"] = strconv.FormatInt(progress.DoneUsersCount, 10)
job.Data["done_files_count"] = strconv.FormatInt(progress.DoneFilesCount, 10)
job.Data["total_posts_count"] = strconv.FormatInt(progress.TotalPostsCount, 10)
job.Data["total_channels_count"] = strconv.FormatInt(progress.TotalChannelsCount, 10)
job.Data["total_users_count"] = strconv.FormatInt(progress.TotalUsersCount, 10)
job.Data["total_files_count"] = strconv.FormatInt(progress.TotalFilesCount, 10)
job.Data["start_time"] = strconv.FormatInt(progress.LastEntityTime, 10)
job.Data["start_post_id"] = progress.LastPostID
job.Data["start_channel_id"] = progress.LastChannelID
job.Data["start_user_id"] = progress.LastUserID
job.Data["start_file_id"] = progress.LastFileID
job.Data["original_start_time"] = strconv.FormatInt(progress.StartAtTime, 10)
job.Data["end_time"] = strconv.FormatInt(progress.EndAtTime, 10)
if err := worker.jobServer.SetJobProgress(job, progress.CurrentProgress()); err != nil {
logger.Error("Worker: Failed to set progress for job", mlog.Err(err))
if err2 := worker.jobServer.SetJobError(job, err); err2 != nil {
logger.Error("Worker: Failed to set error for job", mlog.Err(err2), mlog.NamedErr("set_error", err))
}
return
}
if progress.IsDone(job) {
doClose()
if numFailed > 0 {
logger.Error("Worker: Indexing job finished with bulk write failures; some documents were not indexed",
mlog.Int("num_failed", numFailed),
mlog.Int("done_posts_count", progress.DonePostsCount),
)
appErr := model.NewAppError("IndexerWorker", "ent.elasticsearch.indexer.do_job.bulk_failures.error",
map[string]any{"NumFailed": numFailed}, "", http.StatusInternalServerError)
if err2 := worker.jobServer.SetJobError(job, appErr); err2 != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err2), mlog.NamedErr("set_error", appErr))
}
return
}
if err := worker.jobServer.SetJobSuccess(job); err != nil {
logger.Error("Worker: Failed to set success for job", mlog.Err(err))
if err2 := worker.jobServer.SetJobError(job, err); err2 != nil {
logger.Error("Worker: Failed to set error for job", mlog.Err(err2), mlog.NamedErr("set_error", err))
}
}
logger.Info("Worker: Indexing job finished successfully")
return
}
}
}
}
func (worker *IndexerWorker) IndexBatch(logger mlog.LoggerIFace, progress IndexingProgress, job *model.Job) (IndexingProgress, *model.AppError) {
// an entity's batch is processed if it wasn't specified to be skipped, or if its completed indexing.
if job.Data["index_posts"] != "false" && !progress.DonePosts {
worker.logger.Debug("Worker: indexing post batch...")
return worker.IndexPostsBatch(logger, progress)
}
if job.Data["index_channels"] != "false" && !progress.DoneChannels {
worker.logger.Debug("Worker: indexing channels batch...")
return IndexChannelsBatch(logger, worker.jobServer.Config(), worker.jobServer.Store, worker.addItemToBulkProcessor, progress)
}
if job.Data["index_users"] != "false" && !progress.DoneUsers {
worker.logger.Debug("Worker: indexing users batch...")
return worker.IndexUsersBatch(logger, progress)
}
if job.Data["index_files"] != "false" && !progress.DoneFiles {
worker.logger.Debug("Worker: indexing files batch...")
return worker.IndexFilesBatch(logger, progress)
}
return progress, model.NewAppError("IndexerWorker", "ent.elasticsearch.indexer.index_batch.nothing_left_to_index.error", nil, "", http.StatusInternalServerError)
}
func (worker *IndexerWorker) IndexPostsBatch(logger mlog.LoggerIFace, progress IndexingProgress) (IndexingProgress, *model.AppError) {
var posts []*model.PostForIndexing
tries := 0
for posts == nil {
var err error
posts, err = worker.jobServer.Store.Post().GetPostsBatchForIndexing(progress.LastEntityTime, progress.LastPostID, *worker.jobServer.Config().ElasticsearchSettings.BatchSize)
if err != nil {
if tries >= 10 {
return progress, model.NewAppError("IndexPostsBatch", "ent.elasticsearch.post.get_posts_batch_for_indexing.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
logger.Warn("Failed to get posts batch for indexing. Retrying.", mlog.Err(err))
// Wait a bit before trying again.
time.Sleep(15 * time.Second)
}
tries++
}
// Handle zero messages.
if len(posts) == 0 {
progress.DonePosts = true
progress.LastEntityTime = progress.StartAtTime
return progress, nil
}
lastPost, err := worker.BulkIndexPosts(posts, progress)
if err != nil {
return progress, err
}
// Our exit condition is when the last post's createAt reaches the initial endAtTime
// set during job creation.
if progress.EndAtTime <= lastPost.CreateAt {
progress.DonePosts = true
// We reset the last entity time to the beginning to begin
// indexing of the next set of entities (users, channels etc.)
progress.LastEntityTime = progress.StartAtTime
} else {
progress.LastEntityTime = lastPost.CreateAt
}
progress.LastPostID = lastPost.Id
progress.DonePostsCount += int64(len(posts))
return progress, nil
}
func (worker *IndexerWorker) BulkIndexPosts(posts []*model.PostForIndexing, progress IndexingProgress) (*model.Post, *model.AppError) {
for _, post := range posts {
indexName := BuildPostIndexName(*worker.jobServer.Config().ElasticsearchSettings.AggregatePostsAfterDays,
*worker.jobServer.Config().ElasticsearchSettings.IndexPrefix+IndexBasePosts,
*worker.jobServer.Config().ElasticsearchSettings.IndexPrefix+IndexBasePosts_MONTH, progress.Now, post.CreateAt)
if post.Type == model.PostTypeBurnOnRead {
continue
}
// FIXME(IntegratedBoardMVP): Temporarily excluded
if post.Type == model.PostTypeCard {
continue
}
if post.DeleteAt == 0 {
searchPost := ESPostFromPostForIndexing(post, worker.jobServer.Config().FeatureFlags.MmBlocksEnabled)
data, err := json.Marshal(searchPost)
if err != nil {
worker.logger.Warn("Failed to marshal JSON, skipping this post.", mlog.String("post_id", post.Id), mlog.Err(err))
continue
}
err = worker.addItemToBulkProcessor(indexName, indexOp, searchPost.Id, bytes.NewReader(data))
if err != nil {
worker.logger.Warn("Failed to add item to bulk processor", mlog.String("index_name", indexName), mlog.Err(err))
}
} else {
err := worker.addItemToBulkProcessor(indexName, deleteOp, post.Id, nil)
if err != nil {
worker.logger.Warn("Failed to add item to bulk processor", mlog.String("index_name", indexName), mlog.Err(err))
}
}
}
return &posts[len(posts)-1].Post, nil
}
func (worker *IndexerWorker) IndexFilesBatch(logger mlog.LoggerIFace, progress IndexingProgress) (IndexingProgress, *model.AppError) {
var files []*model.FileForIndexing
tries := 0
for files == nil {
var err error
files, err = worker.jobServer.Store.FileInfo().GetFilesBatchForIndexing(progress.LastEntityTime, progress.LastFileID, true, *worker.jobServer.Config().ElasticsearchSettings.BatchSize)
if err != nil {
if tries >= 10 {
return progress, model.NewAppError("IndexFilesBatch", "ent.elasticsearch.post.get_files_batch_for_indexing.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
logger.Warn("Failed to get files batch for indexing. Retrying.", mlog.Err(err))
// Wait a bit before trying again.
time.Sleep(15 * time.Second)
}
tries++
}
if len(files) == 0 {
progress.DoneFiles = true
progress.LastEntityTime = progress.StartAtTime
return progress, nil
}
lastFile, err := worker.BulkIndexFiles(files, progress)
if err != nil {
return progress, err
}
// Our exit condition is when the last file's createAt reaches the initial endAtTime
// set during job creation.
if progress.EndAtTime <= lastFile.CreateAt {
progress.DoneFiles = true
// We reset the last entity time to the beginning to begin
// indexing of the next set of entities (users, channels etc.)
progress.LastEntityTime = progress.StartAtTime
} else {
progress.LastEntityTime = lastFile.CreateAt
}
progress.LastFileID = lastFile.Id
progress.DoneFilesCount += int64(len(files))
return progress, nil
}
func (worker *IndexerWorker) BulkIndexFiles(files []*model.FileForIndexing, progress IndexingProgress) (*model.FileInfo, *model.AppError) {
for _, file := range files {
indexName := *worker.jobServer.Config().ElasticsearchSettings.IndexPrefix + IndexBaseFiles
if file.ShouldIndex() {
searchFile := ESFileFromFileForIndexing(file)
data, err := json.Marshal(searchFile)
if err != nil {
worker.logger.Warn("Failed to marshal JSON", mlog.Err(err))
continue
}
err = worker.addItemToBulkProcessor(indexName, indexOp, searchFile.Id, bytes.NewReader(data))
if err != nil {
worker.logger.Warn("Failed to add item to bulk processor", mlog.String("index_name", indexName), mlog.Err(err))
}
} else {
err := worker.addItemToBulkProcessor(indexName, deleteOp, file.Id, nil)
if err != nil {
worker.logger.Warn("Failed to add item to bulk processor", mlog.String("index_name", indexName), mlog.Err(err))
}
}
}
return &files[len(files)-1].FileInfo, nil
}
func IndexChannelsBatch(logger mlog.LoggerIFace, config *model.Config, store store.Store, addItemToBulkProcessorFn func(indexName string, indexOp string, docID string, body io.ReadSeeker) error, progress IndexingProgress) (IndexingProgress, *model.AppError) {
var channels []*model.Channel
tries := 0
for channels == nil {
var err error
channels, err = store.Channel().GetChannelsBatchForIndexing(progress.LastEntityTime, progress.LastChannelID, *config.ElasticsearchSettings.BatchSize)
if err != nil {
if tries >= 10 {
return progress, model.NewAppError("IndexerWorker.IndexChannelsBatch", "ent.elasticsearch.index_channels_batch.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
logger.Warn("Failed to get channels batch for indexing. Retrying.", mlog.Err(err))
// Wait a bit before trying again.
time.Sleep(15 * time.Second)
}
tries++
}
if len(channels) == 0 {
progress.DoneChannels = true
progress.LastEntityTime = progress.StartAtTime
return progress, nil
}
lastChannel, err := BulkIndexChannels(config, store, logger, addItemToBulkProcessorFn, channels, progress)
if err != nil {
return progress, err
}
// Our exit condition is when the last channel's createAt reaches the initial endAtTime
// set during job creation.
if progress.EndAtTime <= lastChannel.CreateAt {
progress.DoneChannels = true
// We reset the last entity time to the beginning to begin
// indexing of the next set of entities (users etc.)
progress.LastEntityTime = progress.StartAtTime
} else {
progress.LastEntityTime = lastChannel.CreateAt
}
progress.LastChannelID = lastChannel.Id
progress.DoneChannelsCount += int64(len(channels))
return progress, nil
}
func BulkIndexChannels(config *model.Config,
store store.Store,
logger mlog.LoggerIFace,
addItemToBulkProcessorFn func(indexName string, indexOp string, docID string, body io.ReadSeeker) error,
channels []*model.Channel,
_ IndexingProgress) (*model.Channel, *model.AppError) {
for _, channel := range channels {
indexName := *config.ElasticsearchSettings.IndexPrefix + IndexBaseChannels
var userIDs []string
var err error
if channel.Type == model.ChannelTypePrivate {
userIDs, err = store.Channel().GetAllChannelMemberIdsByChannelId(channel.Id)
if err != nil {
return nil, model.NewAppError("IndexerWorker.BulkIndexChannels", "ent.elasticsearch.getAllChannelMembers.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
rctx := request.EmptyContext(logger)
teamMemberIDs, err := store.Channel().GetTeamMembersForChannel(rctx, channel.Id)
if err != nil {
return nil, model.NewAppError("IndexerWorker.BulkIndexChannels", "ent.elasticsearch.getAllTeamMembers.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
searchChannel := ESChannelFromChannel(channel, userIDs, teamMemberIDs)
data, err := json.Marshal(searchChannel)
if err != nil {
logger.Warn("Failed to marshal JSON", mlog.Err(err))
continue
}
err = addItemToBulkProcessorFn(indexName, indexOp, searchChannel.Id, bytes.NewReader(data))
if err != nil {
logger.Warn("Failed to add item to bulk processor", mlog.String("index_name", indexName), mlog.Err(err))
}
}
return channels[len(channels)-1], nil
}
func (worker *IndexerWorker) IndexUsersBatch(logger mlog.LoggerIFace, progress IndexingProgress) (IndexingProgress, *model.AppError) {
var users []*model.UserForIndexing
tries := 0
for users == nil {
if usersBatch, err := worker.jobServer.Store.User().GetUsersBatchForIndexing(progress.LastEntityTime, progress.LastUserID, *worker.jobServer.Config().ElasticsearchSettings.BatchSize); err != nil {
if tries >= 10 {
return progress, model.NewAppError("IndexerWorker.IndexUsersBatch", "app.user.get_users_batch_for_indexing.get_users.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
logger.Warn("Failed to get users batch for indexing. Retrying.", mlog.Err(err))
// Wait a bit before trying again.
time.Sleep(15 * time.Second)
} else {
users = usersBatch
}
tries++
}
if len(users) == 0 {
progress.DoneUsers = true
progress.LastEntityTime = progress.StartAtTime
return progress, nil
}
lastUser, err := worker.BulkIndexUsers(users, progress)
if err != nil {
return progress, err
}
// Our exit condition is when the last user's createAt reaches the initial endAtTime
// set during job creation.
if progress.EndAtTime <= lastUser.CreateAt {
progress.DoneUsers = true
// We reset the last entity time to the beginning to begin
// indexing of the next set of entities in case they get added in the future.
progress.LastEntityTime = progress.StartAtTime
} else {
progress.LastEntityTime = lastUser.CreateAt
}
progress.LastUserID = lastUser.Id
progress.DoneUsersCount += int64(len(users))
return progress, nil
}
func (worker *IndexerWorker) BulkIndexUsers(users []*model.UserForIndexing, progress IndexingProgress) (*model.UserForIndexing, *model.AppError) {
for _, user := range users {
indexName := *worker.jobServer.Config().ElasticsearchSettings.IndexPrefix + IndexBaseUsers
searchUser := ESUserFromUserForIndexing(user)
data, err := json.Marshal(searchUser)
if err != nil {
worker.logger.Warn("Failed to marshal JSON", mlog.Err(err))
continue
}
err = worker.addItemToBulkProcessor(indexName, indexOp, searchUser.Id, bytes.NewReader(data))
if err != nil {
worker.logger.Warn("Failed to add item to bulk processor", mlog.String("index_name", indexName), mlog.Err(err))
}
}
return users[len(users)-1], nil
}
func initProgress(logger mlog.LoggerIFace, jobServer *jobs.JobServer, job *model.Job, backend string) (IndexingProgress, error) {
progress := IndexingProgress{
Now: time.Now(),
DonePosts: false,
DoneChannels: false,
DoneUsers: false,
DoneFiles: false,
StartAtTime: 0,
EndAtTime: model.GetMillis(),
}
progress, err := parseStartTime(logger, jobServer, progress, job, backend)
if err != nil {
return progress, err
}
progress, err = parseEndTime(logger, jobServer, progress, job, backend)
if err != nil {
return progress, err
}
progress = parseDoneCount(logger, progress, job)
progress = setStartEntityIDs(progress, job)
progress = setEntityCount(logger, jobServer, progress, job)
return progress, nil
}
func parseStartTime(logger mlog.LoggerIFace, jobServer *jobs.JobServer, progress IndexingProgress, job *model.Job, backend string) (IndexingProgress, error) {
// Extract the start time, if it is set.
if startString, ok := job.Data["start_time"]; ok {
startInt, err := strconv.ParseInt(startString, 10, 64)
if err != nil {
logger.Error("Worker: Failed to parse start_time for job", mlog.String("start_time", startString), mlog.Err(err))
appError := model.NewAppError("IndexerWorker", "ent.elasticsearch.indexer.do_job.parse_start_time.error", map[string]any{"Backend": backend}, "", http.StatusInternalServerError).Wrap(err)
if err := jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err), mlog.NamedErr("set_error", appError))
}
return progress, err
}
progress.StartAtTime = startInt
} else {
// Set start time to oldest entity (user, channel or post) in the database.
oldestEntityTime, err := jobServer.Store.Post().GetOldestEntityCreationTime()
if err != nil {
logger.Error("Worker: Failed to fetch oldest post for job.", mlog.String("start_time", startString), mlog.Err(err))
appError := model.NewAppError("IndexerWorker", "ent.elasticsearch.indexer.do_job.get_oldest_entity.error", nil, "", http.StatusInternalServerError).Wrap(err)
if err := jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err), mlog.NamedErr("set_error", appError))
}
return progress, err
}
progress.StartAtTime = oldestEntityTime
}
progress.LastEntityTime = progress.StartAtTime
return progress, nil
}
func parseEndTime(logger mlog.LoggerIFace, jobServer *jobs.JobServer, progress IndexingProgress, job *model.Job, backend string) (IndexingProgress, error) {
if endString, ok := job.Data["end_time"]; ok {
endInt, err := strconv.ParseInt(endString, 10, 64)
if err != nil {
logger.Error("Worker: Failed to parse end_time for job", mlog.String("end_time", endString), mlog.Err(err))
appError := model.NewAppError("IndexerWorker", "ent.elasticsearch.indexer.do_job.parse_end_time.error", map[string]any{"Backend": backend}, "", http.StatusInternalServerError).Wrap(err)
if err := jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job errorv", mlog.Err(err), mlog.NamedErr("set_error", appError))
}
return progress, err
}
progress.EndAtTime = endInt
}
return progress, nil
}
func parseDoneCount(logger mlog.LoggerIFace, progress IndexingProgress, job *model.Job) IndexingProgress {
if count, ok := job.Data["done_posts_count"]; ok {
countInt, err := strconv.ParseInt(count, 10, 64)
if err != nil {
logger.Error("Worker: Failed to parse done_posts_count for job", mlog.String("done_posts_count", count), mlog.Err(err))
}
progress.DonePostsCount = countInt
}
if count, ok := job.Data["done_channels_count"]; ok {
countInt, err := strconv.ParseInt(count, 10, 64)
if err != nil {
logger.Error("Worker: Failed to parse done_channels_count for job", mlog.String("done_channels_count", count), mlog.Err(err))
}
progress.DoneChannelsCount = countInt
}
if count, ok := job.Data["done_users_count"]; ok {
countInt, err := strconv.ParseInt(count, 10, 64)
if err != nil {
logger.Error("Worker: Failed to parse done_users_count for job", mlog.String("done_users_count", count), mlog.Err(err))
}
progress.DoneUsersCount = countInt
}
if count, ok := job.Data["done_files_count"]; ok {
countInt, err := strconv.ParseInt(count, 10, 64)
if err != nil {
logger.Error("Worker: Failed to parse done_files_count for job", mlog.String("done_files_count", count), mlog.Err(err))
}
progress.DoneFilesCount = countInt
}
return progress
}
func setStartEntityIDs(progress IndexingProgress, job *model.Job) IndexingProgress {
if id, ok := job.Data["start_post_id"]; ok {
progress.LastPostID = id
}
if id, ok := job.Data["start_channel_id"]; ok {
progress.LastChannelID = id
}
if id, ok := job.Data["start_user_id"]; ok {
progress.LastUserID = id
}
if id, ok := job.Data["start_file_id"]; ok {
progress.LastFileID = id
}
return progress
}
// entityCountFallback returns the best available fallback for a total entity count when the
// analytics query fails. It prefers a previously stored value from job.Data, then the hardcoded
// estimate, and finally ensures the result is never less than what has already been processed.
func entityCountFallback(job *model.Job, dataKey string, estimate, doneCount int64) int64 {
fallback := estimate
if stored, ok := job.Data[dataKey]; ok {
if v, err := strconv.ParseInt(stored, 10, 64); err == nil && v > 0 {
fallback = v
}
}
if doneCount > fallback {
fallback = doneCount
}
return fallback
}
func setEntityCount(logger mlog.LoggerIFace, jobServer *jobs.JobServer, progress IndexingProgress, job *model.Job) IndexingProgress {
if job.Data["index_posts"] == "true" {
// Counting all posts may fail or timeout when the posts table is large. If this happens, log a warning, but carry
// on with the indexing job anyway. The only issue is that the progress % reporting will be inaccurate.
if count, err := jobServer.Store.Post().AnalyticsPostCount(&model.PostCountOptions{}); err != nil {
fallback := entityCountFallback(job, "total_posts_count", estimatedPostCount, progress.DonePostsCount)
logger.Warn("Worker: Failed to fetch total post count for job. A fallback value will be used for progress reporting.", mlog.Int("fallback_post_count", fallback), mlog.Err(err))
progress.TotalPostsCount = fallback
} else {
progress.TotalPostsCount = count
job.Data["total_posts_count"] = strconv.FormatInt(count, 10)
}
}
if job.Data["index_channels"] == "true" {
// Same possible fail as above can happen when counting channels
if count, err := jobServer.Store.Channel().AnalyticsTypeCount("", ""); err != nil {
fallback := entityCountFallback(job, "total_channels_count", estimatedChannelCount, progress.DoneChannelsCount)
logger.Warn("Worker: Failed to fetch total channel count for job. A fallback value will be used for progress reporting.", mlog.Int("fallback_channel_count", fallback), mlog.Err(err))
progress.TotalChannelsCount = fallback
} else {
progress.TotalChannelsCount = count
job.Data["total_channels_count"] = strconv.FormatInt(count, 10)
}
}
if job.Data["index_users"] == "true" {
// Same possible fail as above can happen when counting users
if count, err := jobServer.Store.User().Count(model.UserCountOptions{
IncludeBotAccounts: true, // This actually doesn't join with the bots table
// since ExcludeRegularUsers is set to false
}); err != nil {
fallback := entityCountFallback(job, "total_users_count", estimatedUserCount, progress.DoneUsersCount)
logger.Warn("Worker: Failed to fetch total user count for job. A fallback value will be used for progress reporting.", mlog.Int("fallback_user_count", fallback), mlog.Err(err))
progress.TotalUsersCount = fallback
} else {
progress.TotalUsersCount = count
job.Data["total_users_count"] = strconv.FormatInt(count, 10)
}
}
if job.Data["index_files"] == "true" {
// Same possible fail as above can happen when counting files
if count, err := jobServer.Store.FileInfo().CountAll(); err != nil {
fallback := entityCountFallback(job, "total_files_count", estimatedFilesCount, progress.DoneFilesCount)
logger.Warn("Worker: Failed to fetch total files count for job. A fallback value will be used for progress reporting.", mlog.Int("fallback_files_count", fallback), mlog.Err(err))
progress.TotalFilesCount = fallback
} else {
progress.TotalFilesCount = count
job.Data["total_files_count"] = strconv.FormatInt(count, 10)
}
}
return progress
}

View File

@@ -0,0 +1,296 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"fmt"
"io"
"maps"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
func TestBulkIndexChannelsWithDeletedChannels(t *testing.T) {
// Create test channels - one active, one deleted
activeChannel := &model.Channel{
Id: "ch1",
Type: model.ChannelTypeOpen,
DeleteAt: 0,
}
deletedChannel := &model.Channel{
Id: "ch2",
Type: model.ChannelTypeOpen,
DeleteAt: 123456,
}
channels := []*model.Channel{activeChannel, deletedChannel}
// Mock store
mockStore := &mocks.Store{}
mockChannelStore := &mocks.ChannelStore{}
mockStore.On("Channel").Return(mockChannelStore)
defer mockStore.AssertExpectations(t)
// Since these are open channels, GetAllChannelMemberIdsByChannelId won't be called
// But GetTeamMembersForChannel will be called for both channels
mockChannelStore.On("GetTeamMembersForChannel", mock.AnythingOfType("*request.Context"), "ch1").Return([]string{"team1"}, nil)
mockChannelStore.On("GetTeamMembersForChannel", mock.AnythingOfType("*request.Context"), "ch2").Return([]string{"team1"}, nil)
// Track which channels were actually indexed
indexedChannels := make(map[string]bool)
// Mock bulk processor function
addItemToBulkProcessorFn := func(_, op, id string, _ io.ReadSeeker) error {
assert.Equal(t, indexOp, op) // Should always be index, not delete
indexedChannels[id] = true
return nil
}
config := &model.Config{}
config.ElasticsearchSettings.IndexPrefix = new("test_")
// Call the function
lastChannel, appErr := BulkIndexChannels(config, mockStore, mlog.CreateConsoleTestLogger(t), addItemToBulkProcessorFn, channels, IndexingProgress{})
// Verify results
require.Nil(t, appErr)
assert.Equal(t, deletedChannel, lastChannel)
assert.True(t, indexedChannels["ch1"], "Active channel should be indexed")
assert.True(t, indexedChannels["ch2"], "Deleted channel should also be indexed")
}
func TestCurrentProgressCapsAt100(t *testing.T) {
tests := []struct {
name string
progress IndexingProgress
expected int64
}{
{
name: "normal progress",
progress: IndexingProgress{
DonePostsCount: 50,
TotalPostsCount: 100,
DoneChannelsCount: 0,
TotalChannelsCount: 100,
DoneUsersCount: 0,
TotalUsersCount: 100,
DoneFilesCount: 0,
TotalFilesCount: 100,
},
expected: 12,
},
{
name: "exactly 100%",
progress: IndexingProgress{
DonePostsCount: 100,
TotalPostsCount: 100,
DoneChannelsCount: 50,
TotalChannelsCount: 50,
DoneUsersCount: 10,
TotalUsersCount: 10,
DoneFilesCount: 5,
TotalFilesCount: 5,
},
expected: 100,
},
{
name: "all totals zero returns 100",
progress: IndexingProgress{},
expected: 100,
},
{
name: "done exceeds total, caps at 100",
progress: IndexingProgress{
DonePostsCount: 20000000,
TotalPostsCount: 10000000,
DoneChannelsCount: 0,
TotalChannelsCount: 100000,
DoneUsersCount: 0,
TotalUsersCount: 10000,
DoneFilesCount: 0,
TotalFilesCount: 100000,
},
expected: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.progress.CurrentProgress())
})
}
}
func TestEntityCountFallback(t *testing.T) {
tests := []struct {
name string
jobData model.StringMap
dataKey string
estimate int64
doneCount int64
expected int64
}{
{
name: "no stored value, uses estimate",
jobData: model.StringMap{},
dataKey: "total_posts_count",
estimate: 10000000,
doneCount: 0,
expected: 10000000,
},
{
name: "stored value preferred over estimate",
jobData: model.StringMap{"total_posts_count": "5000000"},
dataKey: "total_posts_count",
estimate: 10000000,
doneCount: 0,
expected: 5000000,
},
{
name: "doneCount exceeds stored value",
jobData: model.StringMap{"total_posts_count": "5000000"},
dataKey: "total_posts_count",
estimate: 10000000,
doneCount: 7000000,
expected: 7000000,
},
{
name: "doneCount exceeds estimate when no stored value",
jobData: model.StringMap{},
dataKey: "total_posts_count",
estimate: 10000000,
doneCount: 15000000,
expected: 15000000,
},
{
name: "invalid stored value falls back to estimate",
jobData: model.StringMap{"total_posts_count": "not-a-number"},
dataKey: "total_posts_count",
estimate: 10000000,
doneCount: 0,
expected: 10000000,
},
{
name: "zero stored value falls back to estimate",
jobData: model.StringMap{"total_posts_count": "0"},
dataKey: "total_posts_count",
estimate: 10000000,
doneCount: 0,
expected: 10000000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
job := &model.Job{Data: tt.jobData}
result := entityCountFallback(job, tt.dataKey, tt.estimate, tt.doneCount)
assert.Equal(t, tt.expected, result)
})
}
}
func TestSetEntityCount(t *testing.T) {
type entityCountMocks struct {
store *mocks.Store
post *mocks.PostStore
channel *mocks.ChannelStore
user *mocks.UserStore
fileInfo *mocks.FileInfoStore
}
setupEntityCountMocks := func() entityCountMocks {
m := entityCountMocks{
store: &mocks.Store{},
post: &mocks.PostStore{},
channel: &mocks.ChannelStore{},
user: &mocks.UserStore{},
fileInfo: &mocks.FileInfoStore{},
}
m.store.On("Post").Return(m.post)
m.store.On("Channel").Return(m.channel)
m.store.On("User").Return(m.user)
m.store.On("FileInfo").Return(m.fileInfo)
return m
}
allEntitiesEnabled := model.StringMap{
"index_posts": "true",
"index_channels": "true",
"index_users": "true",
"index_files": "true",
}
t.Run("stores counts on success", func(t *testing.T) {
m := setupEntityCountMocks()
m.post.On("AnalyticsPostCount", mock.Anything).Return(int64(500), nil)
m.channel.On("AnalyticsTypeCount", "", model.ChannelType("")).Return(int64(200), nil)
m.user.On("Count", mock.Anything).Return(int64(50), nil)
m.fileInfo.On("CountAll").Return(int64(300), nil)
job := &model.Job{Data: maps.Clone(allEntitiesEnabled)}
progress := setEntityCount(mlog.CreateConsoleTestLogger(t), &jobs.JobServer{Store: m.store}, IndexingProgress{}, job)
assert.Equal(t, int64(500), progress.TotalPostsCount)
assert.Equal(t, int64(200), progress.TotalChannelsCount)
assert.Equal(t, int64(50), progress.TotalUsersCount)
assert.Equal(t, int64(300), progress.TotalFilesCount)
assert.Equal(t, "500", job.Data["total_posts_count"])
assert.Equal(t, "200", job.Data["total_channels_count"])
assert.Equal(t, "50", job.Data["total_users_count"])
assert.Equal(t, "300", job.Data["total_files_count"])
})
t.Run("falls back to job data on query failure", func(t *testing.T) {
m := setupEntityCountMocks()
m.post.On("AnalyticsPostCount", mock.Anything).Return(int64(0), fmt.Errorf("timeout"))
m.channel.On("AnalyticsTypeCount", "", model.ChannelType("")).Return(int64(0), fmt.Errorf("timeout"))
m.user.On("Count", mock.Anything).Return(int64(0), fmt.Errorf("timeout"))
m.fileInfo.On("CountAll").Return(int64(0), fmt.Errorf("timeout"))
jobData := maps.Clone(allEntitiesEnabled)
jobData["total_posts_count"] = "8000000"
jobData["total_channels_count"] = "50000"
jobData["total_users_count"] = "5000"
jobData["total_files_count"] = "75000"
job := &model.Job{Data: jobData}
progress := setEntityCount(mlog.CreateConsoleTestLogger(t), &jobs.JobServer{Store: m.store}, IndexingProgress{}, job)
assert.Equal(t, int64(8000000), progress.TotalPostsCount)
assert.Equal(t, int64(50000), progress.TotalChannelsCount)
assert.Equal(t, int64(5000), progress.TotalUsersCount)
assert.Equal(t, int64(75000), progress.TotalFilesCount)
})
t.Run("uses max of fallback and done count", func(t *testing.T) {
m := setupEntityCountMocks()
m.post.On("AnalyticsPostCount", mock.Anything).Return(int64(0), fmt.Errorf("timeout"))
job := &model.Job{
Data: model.StringMap{
"index_posts": "true",
"index_channels": "false",
"index_users": "false",
"index_files": "false",
"total_posts_count": strconv.FormatInt(estimatedPostCount, 10),
},
}
inputProgress := IndexingProgress{
DonePostsCount: estimatedPostCount + 5000000,
}
progress := setEntityCount(mlog.CreateConsoleTestLogger(t), &jobs.JobServer{Store: m.store}, inputProgress, job)
assert.Equal(t, int64(estimatedPostCount+5000000), progress.TotalPostsCount)
})
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"fmt"
"net/http"
"time"
"github.com/mattermost/logr/v2"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func NewLogger(backend string, mlogger mlog.LoggerIFace, trace bool) *Logger {
return &Logger{
backend: backend,
logger: mlogger,
trace: trace,
}
}
// Logger is a target to pass to the Logger instance of the search backend.
type Logger struct {
backend string
trace bool
logger mlog.LoggerIFace
}
// LogRoundTrip prints the information about request and response.
func (l *Logger) LogRoundTrip(req *http.Request, res *http.Response, err error, start time.Time, dur time.Duration) error {
// Set error level.
// 0 = debug, 1=warn, 2=error
var level int
switch {
case err != nil:
level = 2
case res != nil && res.StatusCode > 0 && res.StatusCode < 500:
level = 0
case res != nil && res.StatusCode > 499:
level = 2
default:
level = 2
}
// Capture fields.
fields := []mlog.Field{
mlog.String("method", req.Method),
mlog.Int("status_code", res.StatusCode),
mlog.String("duration", dur.String()),
mlog.String("url", req.URL.String()),
}
var logFn func(string, ...logr.Field)
switch level {
case 0:
logFn = l.logger.Debug
case 1:
logFn = l.logger.Warn
case 2:
logFn = l.logger.Error
}
logFn(l.backend+" request", fields...)
return nil
}
// RequestBodyEnabled makes the client pass request body to logger
func (l *Logger) RequestBodyEnabled() bool { return l.trace }
// ResponseBodyEnabled makes the client pass response body to logger
func (l *Logger) ResponseBodyEnabled() bool { return l.trace }
func NewBulkIndexerLogger(mlogger mlog.LoggerIFace, name string) BulkIndexerDebugLogger {
return BulkIndexerDebugLogger{
logger: mlogger,
name: name,
}
}
type BulkIndexerDebugLogger struct {
logger mlog.LoggerIFace
name string
}
func (bl BulkIndexerDebugLogger) Printf(str string, params ...any) {
line := fmt.Sprintf(str, params...)
bl.logger.Debug(line, mlog.String("workername", bl.name))
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"strconv"
"github.com/elastic/go-elasticsearch/v8/typedapi/indices/putindextemplate"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func addQueryProperty(mappings *types.TypeMapping, propertyName string, analyzer string) {
// property name must be a text property due to default mappings
if textProp, ok := mappings.Properties[propertyName].(types.TextProperty); ok {
if textProp.Fields == nil {
textProp.Fields = make(map[string]types.Property)
}
textProp.Fields[analyzer] = types.TextProperty{
Analyzer: new("mm_" + analyzer),
Type: "text",
}
mappings.Properties[propertyName] = textProp
}
}
func WithNoriAnalyzer() func(template *types.IndexTemplateMapping) {
return func(template *types.IndexTemplateMapping) {
mlog.Info("using nori analyzer")
addQueryProperty(template.Mappings, "message", "nori")
addQueryProperty(template.Mappings, "attachments", "nori")
template.Settings.Analysis.Tokenizer["nori_tokenizer"] = map[string]any{
"type": "nori_tokenizer",
"decompound_mode": "mixed",
}
template.Settings.Analysis.Analyzer["mm_nori"] = map[string]any{
"char_filter": []string{
"leading_underscores",
"trailing_underscores",
},
"tokenizer": "nori_tokenizer",
"filter": []string{
"nori_readingform",
"nori_part_of_speech",
"lowercase",
},
}
}
}
func WithKuromojiAnalyzer() func(template *types.IndexTemplateMapping) {
return func(template *types.IndexTemplateMapping) {
mlog.Info("using kuromoji analyzer")
addQueryProperty(template.Mappings, "message", "kuromoji")
addQueryProperty(template.Mappings, "attachments", "kuromoji")
template.Settings.Analysis.Tokenizer["kuromoji_tokenizer"] = map[string]any{
"type": "kuromoji_tokenizer",
"mode": "search",
}
template.Settings.Analysis.Analyzer["mm_kuromoji"] = map[string]any{
"char_filter": []string{
"leading_underscores",
"trailing_underscores",
},
"tokenizer": "kuromoji_tokenizer",
"filter": []string{
"kuromoji_baseform",
"kuromoji_part_of_speech",
"ja_stop",
"kuromoji_stemmer",
"lowercase",
},
}
}
}
func WithSmartCNAnalyzer() func(template *types.IndexTemplateMapping) {
return func(template *types.IndexTemplateMapping) {
mlog.Info("using smartcn analyzer")
addQueryProperty(template.Mappings, "message", "smartcn")
addQueryProperty(template.Mappings, "attachments", "smartcn")
template.Settings.Analysis.Analyzer["mm_smartcn"] = map[string]any{
"char_filter": []string{
"leading_underscores",
"trailing_underscores",
},
"tokenizer": "smartcn_tokenizer",
"filter": []string{
"smartcn_stop",
"lowercase",
},
}
}
}
func GetPostTemplate(cfg *model.Config, opts ...func(*types.IndexTemplateMapping)) *putindextemplate.Request {
template := &types.IndexTemplateMapping{
Settings: &types.IndexSettings{
Index: &types.IndexSettings{
NumberOfShards: new(strconv.Itoa(*cfg.ElasticsearchSettings.PostIndexShards)),
NumberOfReplicas: new(strconv.Itoa(*cfg.ElasticsearchSettings.PostIndexReplicas)),
},
Analysis: &types.IndexSettingsAnalysis{
Tokenizer: map[string]types.Tokenizer{},
CharFilter: map[string]types.CharFilter{
"leading_underscores": map[string]any{
"type": "pattern_replace",
"pattern": `(^|[\s\r\n])_`,
"replacement": "$1",
},
"trailing_underscores": map[string]any{
"type": "pattern_replace",
"pattern": `_([\s\r\n]|$)`,
"replacement": "$1",
},
},
Analyzer: map[string]types.Analyzer{
"mm_lowercaser": map[string]any{
"tokenizer": "icu_tokenizer",
"filter": []string{
"icu_normalizer",
"mm_snowball",
"mm_stop",
},
"char_filter": []string{
"leading_underscores",
"trailing_underscores",
},
},
"mm_url": map[string]any{
"tokenizer": "pattern",
"pattern": "\\W",
"lowercase": true,
},
},
Filter: map[string]types.TokenFilter{
"mm_snowball": map[string]any{
"type": "snowball",
"language": "English",
},
"mm_stop": map[string]any{
"type": "stop",
"stopwords": "_english_",
},
},
Normalizer: map[string]types.Normalizer{
"mm_hashtag": map[string]any{
"type": "custom",
"char_filter": []string{},
"filter": []string{"lowercase", "icu_normalizer"},
},
},
},
},
Mappings: &types.TypeMapping{
Properties: map[string]types.Property{
"message": types.TextProperty{
Analyzer: new("mm_lowercaser"),
Type: "text",
},
"attachments": types.TextProperty{
Analyzer: new("mm_lowercaser"),
Type: "text",
},
"urls": types.TextProperty{
Analyzer: new("mm_url"),
Type: "text",
},
"hashtags": types.KeywordProperty{
Type: "keyword",
Normalizer: new("mm_hashtag"),
Store: new(true),
},
"channel_type": types.KeywordProperty{
Type: "keyword",
},
},
},
}
for _, opt := range opts {
opt(template)
}
return &putindextemplate.Request{
IndexPatterns: []string{*cfg.ElasticsearchSettings.IndexPrefix + IndexBasePosts + "*"},
Template: template,
}
}
func GetFileInfoTemplate(cfg *model.Config) *putindextemplate.Request {
mappings := &types.TypeMapping{
Properties: map[string]types.Property{
"name": types.TextProperty{
Analyzer: new("mm_lowercaser"),
Type: "text",
},
"content": types.TextProperty{
Analyzer: new("mm_lowercaser"),
Type: "text",
},
},
}
return &putindextemplate.Request{
IndexPatterns: []string{*cfg.ElasticsearchSettings.IndexPrefix + IndexBaseFiles + "*"},
Template: &types.IndexTemplateMapping{
Settings: &types.IndexSettings{
Index: &types.IndexSettings{
NumberOfShards: new(strconv.Itoa(*cfg.ElasticsearchSettings.PostIndexShards)),
NumberOfReplicas: new(strconv.Itoa(*cfg.ElasticsearchSettings.PostIndexReplicas)),
},
Analysis: &types.IndexSettingsAnalysis{
CharFilter: map[string]types.CharFilter{
"leading_underscores": map[string]any{
"type": "pattern_replace",
"pattern": `(^|[\s\r\n])_`,
"replacement": "$1",
},
"trailing_underscores": map[string]any{
"type": "pattern_replace",
"pattern": `_([\s\r\n]|$)`,
"replacement": "$1",
},
},
Analyzer: map[string]types.Analyzer{
"mm_lowercaser": map[string]any{
"tokenizer": "icu_tokenizer",
"filter": []string{
"icu_normalizer",
"mm_snowball",
"mm_stop",
},
"char_filter": []string{
"leading_underscores",
"trailing_underscores",
},
},
},
Filter: map[string]types.TokenFilter{
"mm_snowball": map[string]any{
"type": "snowball",
"language": "English",
},
"mm_stop": map[string]any{
"type": "stop",
"stopwords": "_english_",
},
},
},
},
Mappings: mappings,
},
}
}
func GetChannelTemplate(cfg *model.Config) *putindextemplate.Request {
mappings := &types.TypeMapping{
Properties: map[string]types.Property{
"name_suggestions": types.KeywordProperty{
Type: "keyword",
},
"team_id": types.KeywordProperty{
Type: "keyword",
},
"user_ids": types.KeywordProperty{
Type: "keyword",
},
"team_member_ids": types.KeywordProperty{
Type: "keyword",
},
"type": types.KeywordProperty{
Type: "keyword",
},
"delete_at": types.LongNumberProperty{
Type: "long",
},
},
}
return &putindextemplate.Request{
IndexPatterns: []string{*cfg.ElasticsearchSettings.IndexPrefix + IndexBaseChannels + "*"},
Template: &types.IndexTemplateMapping{
Settings: &types.IndexSettings{
Index: &types.IndexSettings{
NumberOfShards: new(strconv.Itoa(*cfg.ElasticsearchSettings.ChannelIndexShards)),
NumberOfReplicas: new(strconv.Itoa(*cfg.ElasticsearchSettings.ChannelIndexReplicas)),
},
},
Mappings: mappings,
},
}
}
func GetUserTemplate(cfg *model.Config) *putindextemplate.Request {
mappings := &types.TypeMapping{
Properties: map[string]types.Property{
"suggestions_with_fullname": types.KeywordProperty{
Type: "keyword",
},
"suggestions_without_fullname": types.KeywordProperty{
Type: "keyword",
},
"team_id": types.KeywordProperty{
Type: "keyword",
},
"channel_id": types.KeywordProperty{
Type: "keyword",
},
"delete_at": types.LongNumberProperty{
Type: "long",
},
"roles": types.KeywordProperty{
Type: "keyword",
},
},
}
return &putindextemplate.Request{
IndexPatterns: []string{*cfg.ElasticsearchSettings.IndexPrefix + IndexBaseUsers + "*"},
Template: &types.IndexTemplateMapping{
Settings: &types.IndexSettings{
Index: &types.IndexSettings{
NumberOfShards: new(strconv.Itoa(*cfg.ElasticsearchSettings.UserIndexShards)),
NumberOfReplicas: new(strconv.Itoa(*cfg.ElasticsearchSettings.UserIndexReplicas)),
},
},
Mappings: mappings,
},
}
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"encoding/json"
"fmt"
"slices"
"strings"
"sync"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app/password/hashers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createPost(userId string, channelId string, message string) *model.Post {
post := &model.Post{
Message: message,
ChannelId: channelId,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: userId,
CreateAt: 1000000,
}
post.PreSave()
return post
}
func createChannel(teamId, name, displayName string, channelType model.ChannelType) *model.Channel {
channel := &model.Channel{
TeamId: teamId,
Type: channelType,
Name: name,
DisplayName: displayName,
}
channel.PreSave()
return channel
}
func createUser(username, nickname, firstName, lastName string) *model.User {
user := &model.User{
Username: username,
Password: username,
Nickname: nickname,
FirstName: firstName,
LastName: lastName,
}
if err := user.PreSave(hashers.GetLatestHasher()); err != nil {
return nil
}
return user
}
func createFile(creatorID, channelID, postID, content, name, extension string) *model.FileInfo {
file := &model.FileInfo{
CreatorId: creatorID,
ChannelId: channelID,
PostId: postID,
Content: content,
Name: name,
Extension: extension,
}
file.PreSave()
return file
}
// ClusterRecorder records the request bodies a fake search cluster receives. Its methods are safe
// to call from a test server handler.
type ClusterRecorder struct {
mut sync.Mutex
postsTemplate []byte
searchBodies [][]byte
}
func (r *ClusterRecorder) RecordPostsTemplate(body []byte) {
r.mut.Lock()
defer r.mut.Unlock()
r.postsTemplate = body
}
func (r *ClusterRecorder) RecordSearch(body []byte) {
r.mut.Lock()
defer r.mut.Unlock()
r.searchBodies = append(r.searchBodies, body)
}
func (r *ClusterRecorder) PostsTemplate() []byte {
r.mut.Lock()
defer r.mut.Unlock()
return r.postsTemplate
}
func (r *ClusterRecorder) SearchBodies() [][]byte {
r.mut.Lock()
defer r.mut.Unlock()
return slices.Clone(r.searchBodies)
}
func (r *ClusterRecorder) ResetSearches() {
r.mut.Lock()
defer r.mut.Unlock()
r.searchBodies = nil
}
// CatPluginsResponse builds a response for the CAT plugins API. As in a real CAT plugins response,
// nodes with no plugins have no rows.
func CatPluginsResponse(nodePlugins ...[]string) string {
plugins := make([]string, 0)
for i, pluginNames := range nodePlugins {
for _, name := range pluginNames {
plugins = append(plugins, fmt.Sprintf(`{"id":%q,"component":%q}`, fmt.Sprintf("node-%d", i+1), name))
}
}
return fmt.Sprintf("[%s]", strings.Join(plugins, ","))
}
type indexTemplateRequest struct {
Template struct {
Settings struct {
Analysis struct {
Analyzer map[string]json.RawMessage `json:"analyzer"`
} `json:"analysis"`
} `json:"settings"`
Mappings struct {
Properties map[string]struct {
Fields map[string]struct {
Analyzer string `json:"analyzer"`
} `json:"fields"`
} `json:"properties"`
} `json:"mappings"`
} `json:"template"`
}
func parseIndexTemplate(t *testing.T, body []byte) indexTemplateRequest {
t.Helper()
var request indexTemplateRequest
require.NoError(t, json.Unmarshal(body, &request))
return request
}
// TemplatePropertyFields returns the sub-fields of the named property mapped to their analyzer, as
// declared by an index template request body.
func TemplatePropertyFields(t *testing.T, body []byte, property string) map[string]string {
t.Helper()
fields := map[string]string{}
for name, field := range parseIndexTemplate(t, body).Template.Mappings.Properties[property].Fields {
fields[name] = field.Analyzer
}
return fields
}
// TemplateAnalyzers returns the names of the analyzers declared by an index template request body.
func TemplateAnalyzers(t *testing.T, body []byte) []string {
t.Helper()
declared := parseIndexTemplate(t, body).Template.Settings.Analysis.Analyzer
analyzers := make([]string, 0, len(declared))
for name := range declared {
analyzers = append(analyzers, name)
}
return analyzers
}
// SimpleQueryStringFields collects the field list of every simple_query_string clause in a search
// request body. The clauses are collected in an unspecified order.
func SimpleQueryStringFields(t *testing.T, body []byte) [][]string {
t.Helper()
var doc any
require.NoError(t, json.Unmarshal(body, &doc))
var collected [][]string
var walk func(node any)
walk = func(node any) {
switch typed := node.(type) {
case map[string]any:
if clause, ok := typed["simple_query_string"].(map[string]any); ok {
rawFields, ok := clause["fields"].([]any)
require.True(t, ok, "simple_query_string clause without fields: %v", clause)
fields := make([]string, 0, len(rawFields))
for _, rawField := range rawFields {
field, ok := rawField.(string)
require.True(t, ok, "non-string field name: %v", rawField)
fields = append(fields, field)
}
collected = append(collected, fields)
}
for _, value := range typed {
walk(value)
}
case []any:
for _, value := range typed {
walk(value)
}
}
}
walk(doc)
return collected
}
// RequireOnlyBaseFields asserts that the given search field lists cover the base message and
// attachments fields and target no analyzer sub-field such as message.nori.
func RequireOnlyBaseFields(t *testing.T, fields [][]string) {
t.Helper()
require.Contains(t, fields, []string{"message"})
require.Contains(t, fields, []string{"attachments"})
for _, fieldList := range fields {
for _, field := range fieldList {
require.NotContains(t, field, ".", "unexpected analyzer sub-field in %v", fieldList)
}
}
}
func CheckMatchesEqual(t *testing.T, expected model.PostSearchMatches, actual map[string][]string) {
a := assert.New(t)
a.Len(actual, len(expected), "Received matches for a different number of posts")
for postId, expectedMatches := range expected {
a.ElementsMatch(expectedMatches, actual[postId], fmt.Sprintf("%v: expected %v, got %v", postId, expectedMatches, actual[postId]))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"strconv"
"strings"
)
func GetVersionComponents(version string) (int, int, int, error) {
spl := strings.Split(version, ".")
major, err := strconv.Atoi(spl[0])
if err != nil {
return 0, 0, 0, err
}
minor, err := strconv.Atoi(spl[1])
if err != nil {
return 0, 0, 0, err
}
patch, err := strconv.Atoi(spl[2])
if err != nil {
return 0, 0, 0, err
}
return major, minor, patch, nil
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package common
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetVersionComponents(t *testing.T) {
testCases := []struct {
Name string
Version string
ExpectedMajor int
ExpectedMinor int
ExpectedPatch int
ExpectedError bool
}{
{
Name: "Should error if version format is invalid",
Version: "invalid",
ExpectedMajor: 0,
ExpectedMinor: 0,
ExpectedPatch: 0,
ExpectedError: true,
},
{
Name: "Should work correctly if version has three valid components",
Version: "7.2.3",
ExpectedMajor: 7,
ExpectedMinor: 2,
ExpectedPatch: 3,
ExpectedError: false,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
major, minor, patch, err := GetVersionComponents(tc.Version)
if tc.ExpectedError {
require.Error(t, err)
}
assert.Equal(t, tc.ExpectedMajor, major)
assert.Equal(t, tc.ExpectedMinor, minor)
assert.Equal(t, tc.ExpectedPatch, patch)
})
}
}

View File

@@ -0,0 +1,329 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/elastic/go-elasticsearch/v8"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
const (
aggregatorJobPollingInterval = 15 * time.Second
indexDeletionBatchSize = 20
)
type ElasticsearchAggregatorInterfaceImpl struct {
Server *app.Server
}
type ElasticsearchAggregatorWorker struct {
name string
// stateMut protects stopCh and stopped and helps enforce
// ordering in case subsequent Run or Stop calls are made.
stateMut sync.Mutex
stopCh chan struct{}
stopped bool
stoppedCh chan bool
jobs chan model.Job
jobServer *jobs.JobServer
logger mlog.LoggerIFace
fileBackend filestore.FileBackend
client *elasticsearch.TypedClient
license func() *model.License
}
func (esi *ElasticsearchAggregatorInterfaceImpl) MakeWorker() model.Worker {
const workerName = "EnterpriseElasticsearchAggregator"
worker := ElasticsearchAggregatorWorker{
name: workerName,
stoppedCh: make(chan bool, 1),
jobs: make(chan model.Job),
jobServer: esi.Server.Jobs,
logger: esi.Server.Jobs.Logger().With(mlog.String("worker_name", workerName)),
fileBackend: esi.Server.Platform().FileBackend(),
license: esi.Server.License,
stopped: true,
}
return &worker
}
func (worker *ElasticsearchAggregatorWorker) Run() {
worker.stateMut.Lock()
// We have to re-assign the stop channel again, because
// it might happen that the job was restarted due to a config change.
if worker.stopped {
worker.stopped = false
worker.stopCh = make(chan struct{})
} else {
worker.stateMut.Unlock()
return
}
// Run is called from a separate goroutine and doesn't return.
// So we cannot Unlock in a defer clause.
worker.stateMut.Unlock()
worker.logger.Debug("Worker Started")
defer func() {
worker.logger.Debug("Worker Finished")
worker.stoppedCh <- true
}()
client, err := createTypedClient(worker.logger, worker.jobServer.Config(), worker.fileBackend, false)
if err != nil {
worker.logger.Error("Worker Failed to Create Client", mlog.Err(err))
return
}
worker.client = client
for {
select {
case <-worker.stopCh:
worker.logger.Debug("Worker Received stop signal")
return
case job := <-worker.jobs:
worker.DoJob(&job)
}
}
}
func (worker *ElasticsearchAggregatorWorker) IsEnabled(cfg *model.Config) bool {
if license := worker.license(); license == nil || !*license.Features.Elasticsearch {
return false
}
if *cfg.ElasticsearchSettings.EnableIndexing {
return true
}
return false
}
func (worker *ElasticsearchAggregatorWorker) Stop() {
worker.stateMut.Lock()
defer worker.stateMut.Unlock()
// Set to close, and if already closed before, then return.
if worker.stopped {
return
}
worker.stopped = true
worker.logger.Debug("Worker Stopping")
close(worker.stopCh)
<-worker.stoppedCh
}
func (worker *ElasticsearchAggregatorWorker) JobChannel() chan<- model.Job {
return worker.jobs
}
func (worker *ElasticsearchAggregatorWorker) DoJob(job *model.Job) {
logger := worker.logger.With(jobs.JobLoggerFields(job)...)
logger.Debug("Worker: Received a new candidate job.")
defer worker.jobServer.HandleJobPanic(logger, job)
var appErr *model.AppError
job, appErr = worker.jobServer.ClaimJob(job)
if appErr != nil {
logger.Warn("Worker: Error occurred while trying to claim job", mlog.Err(appErr))
return
} else if job == nil {
return
}
logger.Info("Worker: Aggregation job claimed by worker")
var cancelContext request.CTX = request.EmptyContext(worker.logger)
cancelCtx, cancelCancelWatcher := context.WithCancel(context.Background())
cancelWatcherChan := make(chan struct{}, 1)
cancelContext = cancelContext.WithContext(cancelCtx)
go worker.jobServer.CancellationWatcher(cancelContext, job.Id, cancelWatcherChan)
defer cancelCancelWatcher()
rctx := request.EmptyContext(worker.logger)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
cutoff := today.AddDate(0, 0, -*worker.jobServer.Config().ElasticsearchSettings.AggregatePostsAfterDays+1)
// Get all the daily Elasticsearch post indexes to work out which days aren't aggregated yet.
dateFormat := *worker.jobServer.Config().ElasticsearchSettings.IndexPrefix + common.IndexBasePosts + "_2006_01_02"
datedIndexes := []time.Time{}
postIndexesResult, err := worker.client.API.Indices.
Get(*worker.jobServer.Config().ElasticsearchSettings.IndexPrefix + common.IndexBasePosts + "_*").
Do(rctx.Context())
if err != nil {
appError := model.NewAppError("ElasticsearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.get_indexes.error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
worker.setJobError(logger, job, appError)
return
}
for index := range postIndexesResult {
var indexDate time.Time
indexDate, err = time.Parse(dateFormat, index)
if err != nil {
logger.Warn("Failed to parse date from posts index. Ignoring index.", mlog.String("index", index))
} else {
datedIndexes = append(datedIndexes, indexDate)
}
}
// Work out how far back the reindexing (and index deletion) needs to go.
var oldestDay time.Time
oldestDayFound := false
indexesToPurge := []string{}
for _, date := range datedIndexes {
if date.Before(cutoff) {
logger.Debug("Worker: Post index identified for purging", mlog.Time("date", date))
indexesToPurge = append(indexesToPurge, date.Format(dateFormat))
if !oldestDayFound || oldestDay.After(date) {
oldestDay = date
oldestDayFound = true
}
} else {
logger.Debug("Worker: Post index is within the range to keep", mlog.Time("date", date))
}
}
if !oldestDayFound {
// Nothing to purge.
logger.Info("Worker: Aggregation job completed. Nothing to aggregate.")
worker.setJobSuccess(logger, job)
return
}
// Trigger a reindexing job with the appropriate dates.
reindexingStartDate := oldestDay
reindexingEndDate := cutoff
logger.Info("Worker: Aggregation job reindexing", mlog.String("start_date", reindexingStartDate.Format("2006-01-02")), mlog.String("end_date", reindexingEndDate.Format("2006-01-02")))
var indexJob *model.Job
if indexJob, appErr = worker.jobServer.CreateJob(
rctx,
model.JobTypeElasticsearchPostIndexing,
map[string]string{
"start_time": strconv.FormatInt(reindexingStartDate.UnixNano()/int64(time.Millisecond), 10),
"end_time": strconv.FormatInt(reindexingEndDate.UnixNano()/int64(time.Millisecond), 10),
},
); appErr != nil {
logger.Error("Worker: Failed to create indexing job.", mlog.Err(appErr))
appError := model.NewAppError("ElasticsearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.create_index_job.error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(appErr)
worker.setJobError(logger, job, appError)
return
}
for {
select {
case <-cancelWatcherChan:
logger.Info("Worker: Aggregation job has been canceled via CancellationWatcher")
worker.setJobCanceled(logger, job)
return
case <-worker.stopCh:
logger.Info("Worker: Aggregation job has been canceled via Worker Stop")
worker.setJobCanceled(logger, job)
return
case <-time.After(aggregatorJobPollingInterval):
// Get the details of the indexing job we are waiting on.
indexJob, err = worker.jobServer.Store.Job().Get(rctx, indexJob.Id)
if err != nil {
var appErr *model.AppError
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
appErr = model.NewAppError("DoJob", "app.job.get.app_error", nil, "", http.StatusNotFound).Wrap(nfErr)
default:
appErr = model.NewAppError("DoJob", "app.job.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
worker.setJobError(logger, job, appErr)
return
}
// Wait for the aggregation job to finish.
// On success, we delete the old indexes.
// Otherwise, fail the job.
switch indexJob.Status {
case model.JobStatusSuccess:
// We limit the number of indexes to delete at one shot.
// A minor side-effect of this is that the aggregation job status
// will be redundantly queried multiple times, but that's not a major bottleneck.
curWindow := indexesToPurge
deleteMore := false
if len(indexesToPurge) > indexDeletionBatchSize {
curWindow = indexesToPurge[:indexDeletionBatchSize]
indexesToPurge = indexesToPurge[indexDeletionBatchSize:]
deleteMore = true
}
// Delete indexes
if _, err = worker.client.Indices.Delete(strings.Join(curWindow, ",")).Do(rctx.Context()); err != nil {
appError := model.NewAppError("ElasticsearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.delete_indexes.error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
logger.Error("Worker: Failed to delete indexes for job", mlog.String("workername", worker.name), mlog.String("job_id", job.Id), mlog.Err(appError))
worker.setJobError(logger, job, appError)
return
}
if !deleteMore {
// Job done. Set the status to success.
logger.Info("Worker: Aggregation job finished successfully")
worker.setJobSuccess(logger, job)
return
}
case model.JobStatusPending, model.JobStatusInProgress:
// Indexing job is in progress or pending. Update the progress of this job.
if err := worker.jobServer.SetJobProgress(job, indexJob.Progress); err != nil {
logger.Error("Worker: Failed to set progress for job", mlog.Err(err))
worker.setJobError(logger, job, err)
return
}
default:
// error case
appError := model.NewAppError("ElasticsearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.index_job_failed.error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError)
logger.Error("Worker: Index aggregation job failed", mlog.Err(appError))
worker.setJobError(logger, job, appError)
return
}
}
}
}
func (worker *ElasticsearchAggregatorWorker) setJobSuccess(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobSuccess(job); err != nil {
logger.Error("Worker: Failed to set success for job", mlog.Err(err))
worker.setJobError(logger, job, err)
}
}
func (worker *ElasticsearchAggregatorWorker) setJobError(logger mlog.LoggerIFace, job *model.Job, appError *model.AppError) {
if err := worker.jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err))
}
}
func (worker *ElasticsearchAggregatorWorker) setJobCanceled(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobCanceled(job); err != nil {
logger.Error("Worker: Failed to mark job as canceled", mlog.Err(err))
}
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
)
func TestElasticsearchAggregation(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t)
rctx := request.TestContext(t)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockUserStore.On("GetAllProfiles", mock.Anything).Return(nil, nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockJobStore := mocks.JobStore{}
mockJobStore.On("Save", mock.AnythingOfType("*model.Job")).Return(&model.Job{}, nil)
mockJobStore.On("UpdateStatus", mock.AnythingOfType("string"), model.JobStatusSuccess).Return(&model.Job{}, nil)
mockJobStore.On("Get", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).Return(&model.Job{
Status: model.JobStatusSuccess,
}, nil)
mockJobStore.On("UpdateStatusOptimistically",
mock.AnythingOfType("string"),
model.JobStatusPending,
model.JobStatusInProgress).
Return(&model.Job{}, nil)
mockJobStore.On("GetAllByType", mock.AnythingOfType("string")).Return([]*model.Job{{
Id: "abcxyz123",
Type: "EnterpriseElasticsearchIndexer",
Status: model.JobStatusCanceled,
}}, nil)
mockStore := th.App.Srv().Platform().Store.(*mocks.Store)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("Job").Return(&mockJobStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
aggImpl := ElasticsearchAggregatorInterfaceImpl{Server: th.Server}
// Register search engine
th.App.SearchEngine().RegisterElasticsearchEngine(&ElasticsearchInterfaceImpl{
Platform: th.Server.Platform(),
})
// Set up the state for the tests.
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 1
*cfg.ElasticsearchSettings.AggregatePostsAfterDays = 1
*cfg.SqlSettings.DisableDatabaseSearch = true
})
esImpl := th.App.SearchEngine().ElasticsearchEngine
appErr := esImpl.Start(context.Background())
if appErr != nil && appErr.Id != "ent.elasticsearch.start.already_started.app_error" {
require.Fail(t, "failed to start elasticsearch: %v", appErr)
}
require.Nil(t, esImpl.PurgeIndexes(rctx))
post := &model.Post{
Id: model.NewId(),
ChannelId: "channel",
Message: "hi",
}
for i := range indexDeletionBatchSize + 1 {
indexPost(t, th, esImpl.(*ElasticsearchInterfaceImpl),
post,
time.Now().Add(-time.Duration(4+i)*24*time.Hour))
}
job := &model.Job{
Id: model.NewId(),
Type: model.JobTypeElasticsearchPostAggregation,
Status: model.JobStatusPending,
}
_, err := th.Server.Store().Job().Save(job)
require.NoError(t, err)
worker := aggImpl.MakeWorker().(*ElasticsearchAggregatorWorker)
worker.client = createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
worker.jobServer.Store = mockStore
indexingImpl := ElasticsearchIndexerInterfaceImpl{
Server: th.App.Srv(),
}
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, indexingImpl.MakeWorker(), nil)
worker.DoJob(job)
// We assert the minimum number of calls to verify that
// batching is working correctly. Because job().Get() will happen
// in each iteration.
numCalls := 0
for _, call := range mockJobStore.Calls {
if call.Method == "Get" {
numCalls++
}
}
assert.GreaterOrEqual(t, numCalls, 8, "Unexpected number of Jobstore.Get calls")
}
func TestElasticsearchAggregationSkipDuringBulkIndexing(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockJobStore := mocks.JobStore{}
mockStore := th.App.Srv().Platform().Store.(*mocks.Store)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("Job").Return(&mockJobStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
aggImpl := ElasticsearchAggregatorInterfaceImpl{Server: th.Server}
aggImpl.Server.Jobs.Store = mockStore
// Register search engine
th.App.SearchEngine().RegisterElasticsearchEngine(&ElasticsearchInterfaceImpl{
Platform: th.Server.Platform(),
})
// Set up the state for the tests.
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 1
*cfg.ElasticsearchSettings.AggregatePostsAfterDays = 1
*cfg.SqlSettings.DisableDatabaseSearch = true
})
sched := aggImpl.MakeScheduler()
// Pass pending jobs as true
job, appErr := sched.ScheduleJob(th.Context, th.App.Config(), true, nil)
require.Nil(t, job)
require.Nil(t, appErr)
mockJobStore.AssertNotCalled(t, "GetCountByStatusAndType")
}
func indexPost(t *testing.T, th *api4.TestHelper, esImpl *ElasticsearchInterfaceImpl, post *model.Post, createTime time.Time) { //nolint:unused
t.Helper()
indexName := common.BuildPostIndexName(*th.Server.Config().ElasticsearchSettings.AggregatePostsAfterDays,
common.IndexBasePosts,
common.IndexBasePosts_MONTH,
createTime.Add(-1*24*time.Hour),
model.GetMillisForTime(createTime),
)
searchPost, err := common.ESPostFromPost(post, "teamID", "O", true)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(*esImpl.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second)
defer cancel()
_, err = esImpl.client.Index(indexName).
Id(post.Id).
Document(searchPost).
Do(ctx)
require.NoError(t, err)
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
ejobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs"
)
type ElasticSearchAggregatorScheduler struct {
jobServer *jobs.JobServer
server *app.Server
}
func (s *ElasticSearchAggregatorScheduler) Enabled(cfg *model.Config) bool {
if license := s.server.License(); license == nil || !*license.Features.Elasticsearch {
return false
}
if *cfg.ElasticsearchSettings.EnableIndexing {
return true
}
return false
}
func (s *ElasticSearchAggregatorScheduler) NextScheduleTime(cfg *model.Config, now time.Time, pendingJobs bool, lastSuccessfulJob *model.Job) *time.Time {
parsedTime, err := time.Parse("15:04", *cfg.ElasticsearchSettings.PostsAggregatorJobStartTime)
if err != nil {
s.server.Log().Error("Cannot determine next schedule time for elastic search post aggregator. PostsAggregatorJobStartTime config value is invalid.", mlog.Err(err))
return nil
}
return jobs.GenerateNextStartDateTime(now, parsedTime)
}
func (s *ElasticSearchAggregatorScheduler) ScheduleJob(rctx request.CTX, _ *model.Config, pendingJobs bool, _ *model.Job) (*model.Job, *model.AppError) {
if pendingJobs {
s.server.Log().Warn("An aggregator job is already running. Skipping.")
return nil, nil
}
// Don't schedule a job if we already have a running bulk indexing job
count, err := s.jobServer.Store.Job().GetCountByStatusAndType(model.JobStatusInProgress, model.JobTypeElasticsearchPostIndexing)
if err != nil {
return nil, model.NewAppError(
"ScheduleJob",
model.NoTranslation,
nil,
"",
http.StatusInternalServerError).Wrap(err)
}
if count > 0 {
return nil, nil
}
return s.jobServer.CreateJob(rctx, model.JobTypeElasticsearchPostAggregation, nil)
}
func (esi *ElasticsearchAggregatorInterfaceImpl) MakeScheduler() ejobs.Scheduler {
return &ElasticSearchAggregatorScheduler{
server: esi.Server,
jobServer: esi.Server.Jobs,
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"fmt"
"time"
elastic "github.com/elastic/go-elasticsearch/v8"
esTypes "github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
)
type BulkClient interface {
IndexOp(op esTypes.IndexOperation, doc any) error
DeleteOp(op esTypes.DeleteOperation) error
Flush() error
Stop() error
}
// NewBulk returns a BulkClient, with the specific implementation depending on
// the specified thresholds in bulkSettings.
// NewBulk will return an error if bulkSettings.FlushNumReqs and
// bulkSettings.FlushBytes are both non-zero: the support of these thresholds
// by the implementations of BulkClient is mutually exclusive.
func NewBulk(bulkSettings common.BulkSettings,
client *elastic.TypedClient,
reqTimeout time.Duration,
logger mlog.LoggerIFace,
) (BulkClient, error) {
if bulkSettings.FlushBytes == 0 &&
bulkSettings.FlushInterval == 0 &&
bulkSettings.FlushNumReqs == 0 {
return nil, fmt.Errorf("at least one of FlushBytes, FlushInterval or FlushNumReqs should be non-zero")
}
if bulkSettings.FlushBytes > 0 && bulkSettings.FlushNumReqs > 0 {
return nil, fmt.Errorf(
"one of bulkSettings.FlushBytes (set to %d) or bulkSettings.FlushNumReqs (set to %d) should be zero",
bulkSettings.FlushBytes,
bulkSettings.FlushNumReqs,
)
}
var bulkClient BulkClient
var err error
if bulkSettings.FlushBytes > 0 {
bulkClient, err = NewDataBulkClient(bulkSettings, client, reqTimeout, logger)
if err != nil {
return nil, err
}
} else {
bulkClient, err = NewReqBulkClient(bulkSettings, client, reqTimeout, logger)
if err != nil {
return nil, err
}
}
return bulkClient, nil
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"sync"
"time"
elastic "github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/esutil"
esTypes "github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
)
// DataBulkClient is an Elasticsearch bulk client based on the
// go-elasticsearch/v8/esutil.BulkIndexer type.
// It supports time- and size-based thresholds, but not a threshold on number
// of requests.
type DataBulkClient struct {
mut sync.Mutex
indexer esutil.BulkIndexer
client *elastic.TypedClient
bulkSettings common.BulkSettings
reqTimeout time.Duration
logger mlog.LoggerIFace
}
func NewDataBulkClient(bulkSettings common.BulkSettings,
client *elastic.TypedClient,
reqTimeout time.Duration,
logger mlog.LoggerIFace,
) (*DataBulkClient, error) {
if bulkSettings.FlushNumReqs > 0 {
return nil, fmt.Errorf("DataBulkClient does not support a threshold on number of requests")
}
indexer, err := newIndexer(client, bulkSettings, logger)
if err != nil {
return nil, err
}
return &DataBulkClient{
indexer: indexer,
client: client,
bulkSettings: bulkSettings,
reqTimeout: reqTimeout,
logger: logger,
}, nil
}
func newIndexer(client *elastic.TypedClient, bulkSettings common.BulkSettings, logger mlog.LoggerIFace) (esutil.BulkIndexer, error) {
// A zeroed FlushInterval means that there should be no time-based flush,
// but esutil.BulkIndexer defaults to 30 seconds if the interval is zero,
// so we pick a large enough interval
interval := bulkSettings.FlushInterval
if interval == 0 {
interval = 1 * time.Hour
}
return esutil.NewBulkIndexer(esutil.BulkIndexerConfig{
FlushBytes: bulkSettings.FlushBytes,
FlushInterval: interval,
Client: client,
OnError: func(ctx context.Context, err error) {
logger.Error("indexer error", mlog.Err(err))
},
OnFlushStart: func(ctx context.Context) context.Context {
logger.Debug("elasticsearch bulk indexer flush started")
return ctx
},
OnFlushEnd: func(context.Context) {
logger.Debug("elasticsearch bulk indexer flush ended")
},
})
}
func (b *DataBulkClient) onSuccess(_ context.Context, item esutil.BulkIndexerItem, _ esutil.BulkIndexerResponseItem) {
b.logger.Info("successfully added new bulk operation",
mlog.String("action", item.Action),
mlog.String("index", item.Index),
mlog.String("document_id", item.DocumentID),
)
}
func (b *DataBulkClient) onFailure(_ context.Context, item esutil.BulkIndexerItem, _ esutil.BulkIndexerResponseItem, err error) {
b.logger.Info("failed to add new bulk operation",
mlog.String("action", item.Action),
mlog.String("index", item.Index),
mlog.String("document_id", item.DocumentID),
mlog.Err(err),
)
}
func (b *DataBulkClient) IndexOp(op esTypes.IndexOperation, doc any) error {
b.mut.Lock()
defer b.mut.Unlock()
var bodyReader io.ReadSeeker
switch v := doc.(type) {
case []byte:
bodyReader = bytes.NewReader(v)
case json.RawMessage:
bodyReader = bytes.NewReader(v)
default:
body, err := json.Marshal(doc)
if err != nil {
return err
}
bodyReader = bytes.NewReader(body)
}
ctx, cancel := context.WithTimeout(context.Background(), b.reqTimeout)
defer cancel()
return b.indexer.Add(ctx, esutil.BulkIndexerItem{
Index: *op.Index_,
Action: "index",
DocumentID: *op.Id_,
Body: bodyReader,
OnSuccess: b.onSuccess,
OnFailure: b.onFailure,
})
}
func (b *DataBulkClient) DeleteOp(op esTypes.DeleteOperation) error {
b.mut.Lock()
defer b.mut.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), b.reqTimeout)
defer cancel()
return b.indexer.Add(ctx, esutil.BulkIndexerItem{
Index: *op.Index_,
Action: "delete",
DocumentID: *op.Id_,
Body: nil,
OnSuccess: b.onSuccess,
OnFailure: b.onFailure,
})
}
func (b *DataBulkClient) _stop() error {
ctx, cancel := context.WithTimeout(context.Background(), b.reqTimeout)
defer cancel()
return b.indexer.Close(ctx)
}
func (b *DataBulkClient) Flush() error {
b.mut.Lock()
defer b.mut.Unlock()
// The esutil.BulkIndexer cannot be manually flushed, but it can be closed,
// which does flush all the contents.
if err := b._stop(); err != nil {
return fmt.Errorf("failed to close the BulkIndexer: %w", err)
}
// But calling Close essentially kills all the running processes, so we have
// to create a new one in order to restart it
indexer, err := newIndexer(b.client, b.bulkSettings, b.logger)
if err != nil {
return fmt.Errorf("failed to restart the BulkIndexer: %w", err)
}
b.indexer = indexer
return nil
}
func (b *DataBulkClient) Stop() error {
b.mut.Lock()
defer b.mut.Unlock()
b.logger.Info("Stopping Bulk processor")
return b._stop()
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"context"
"fmt"
"testing"
"time"
"github.com/elastic/go-elasticsearch/v8/esutil"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/stretchr/testify/require"
)
// setupDataBulkClient creates a test data bulk client with common setup
func setupDataBulkClient(t *testing.T, flushBytes int, flushInterval time.Duration) *DataBulkClient {
th := api4.SetupEnterprise(t)
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
bulkClient, err := NewDataBulkClient(
common.BulkSettings{
FlushBytes: flushBytes,
FlushInterval: flushInterval,
FlushNumReqs: 0, // DataBulkClient doesn't support FlushNumReqs
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log())
require.NoError(t, err)
return bulkClient
}
func flushAndGetStats(t *testing.T, b *DataBulkClient) esutil.BulkIndexerStats {
t.Helper()
// Close the indexer to flush
err := b.indexer.Close(context.Background())
require.NoError(t, err)
// Get the stats
stats := b.indexer.Stats()
// Restart the indexer
newIdxr, err := newIndexer(b.client, b.bulkSettings, b.logger)
require.NoError(t, err)
b.indexer = newIdxr
return stats
}
func TestDataIndexOp(t *testing.T) {
t.Run("single index operation", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
// Check that the request got added
stats := bulkClient.indexer.Stats()
require.Equal(t, 1, int(stats.NumAdded))
require.Equal(t, 0, int(stats.NumIndexed))
// Flush, and check that the document was indexed
stats = flushAndGetStats(t, bulkClient)
require.Equal(t, 1, int(stats.NumIndexed))
})
t.Run("multiple index operations", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
for range 5 {
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
}
// Check that the requests got added
stats := bulkClient.indexer.Stats()
require.Equal(t, 5, int(stats.NumAdded))
// Flush, and check that the documents were indexed
stats = flushAndGetStats(t, bulkClient)
require.Equal(t, 5, int(stats.NumIndexed))
})
t.Run("index operation with json.RawMessage", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
docId := model.NewId()
jsonData := []byte(`{"message": "test raw message"}`)
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(docId),
}, jsonData)
require.NoError(t, err)
// Check that the request got added
stats := bulkClient.indexer.Stats()
require.Equal(t, 1, int(stats.NumAdded))
// Flush, and check that the document was indexed
stats = flushAndGetStats(t, bulkClient)
require.Equal(t, 1, int(stats.NumIndexed))
})
t.Run("index operation with byte slice", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
docId := model.NewId()
data := []byte(`{"message": "test byte slice"}`)
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(docId),
}, data)
require.NoError(t, err)
// Check that the request got added
stats := bulkClient.indexer.Stats()
require.Equal(t, 1, int(stats.NumAdded))
// Flush, and check that the document was indexed
stats = flushAndGetStats(t, bulkClient)
require.Equal(t, 1, int(stats.NumIndexed))
})
}
func TestDataDeleteOp(t *testing.T) {
t.Run("single delete operation", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
// Index a new post and flush
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.NoError(t, bulkClient.Flush())
err = bulkClient.DeleteOp(types.DeleteOperation{
Index_: new("testindex"),
Id_: new(post.Id),
})
require.NoError(t, err)
// Check that the request got added
stats := bulkClient.indexer.Stats()
require.Equal(t, 1, int(stats.NumAdded))
require.Equal(t, 0, int(stats.NumDeleted))
// Flush, and check that the document was deleted
stats = flushAndGetStats(t, bulkClient)
fmt.Println(stats)
require.Equal(t, 1, int(stats.NumDeleted))
})
t.Run("multiple delete operations", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
posts := make([]string, 3)
// Index three new posts and flush
for i := range 3 {
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
posts[i] = post.Id
}
require.NoError(t, bulkClient.Flush())
for _, id := range posts {
err := bulkClient.DeleteOp(types.DeleteOperation{
Index_: new("testindex"),
Id_: new(id),
})
require.NoError(t, err)
}
// Check that the requests got added
stats := bulkClient.indexer.Stats()
require.Equal(t, 3, int(stats.NumAdded))
require.Equal(t, 0, int(stats.NumDeleted))
// Flush, and check that the documents were deleted
stats = flushAndGetStats(t, bulkClient)
fmt.Println(stats)
require.Equal(t, 3, int(stats.NumDeleted))
})
}
func TestDataFlush(t *testing.T) {
t.Run("flush with pending operations", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
err = bulkClient.Flush()
require.NoError(t, err)
})
t.Run("flush with no pending operations", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
t.Cleanup(func() {
err := bulkClient.Stop()
require.NoError(t, err)
})
err := bulkClient.Flush()
require.NoError(t, err)
})
}
func TestDataStop(t *testing.T) {
t.Run("stop with pending operations", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
err = bulkClient.Stop()
require.NoError(t, err)
})
t.Run("stop with no pending operations", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 0) // 1MB flush threshold
err := bulkClient.Stop()
require.NoError(t, err)
})
t.Run("stop with periodic flusher", func(t *testing.T) {
bulkClient := setupDataBulkClient(t, 1024*1024, 100*time.Millisecond)
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
// Stop should flush pending operations and stop the periodic flusher
err = bulkClient.Stop()
require.NoError(t, err)
})
}
func TestDataNewDataBulkClient(t *testing.T) {
th := api4.SetupEnterprise(t)
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
t.Run("valid configuration", func(t *testing.T) {
bulkClient, err := NewDataBulkClient(
common.BulkSettings{
FlushBytes: 1024,
FlushInterval: 100 * time.Millisecond,
FlushNumReqs: 0,
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log())
require.NoError(t, err)
require.NotNil(t, bulkClient)
err = bulkClient.Stop()
require.NoError(t, err)
})
t.Run("invalid configuration with FlushNumReqs", func(t *testing.T) {
bulkClient, err := NewDataBulkClient(
common.BulkSettings{
FlushBytes: 1024,
FlushInterval: 100 * time.Millisecond,
FlushNumReqs: 10, // This should cause an error
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log())
require.Error(t, err)
require.Nil(t, bulkClient)
require.Contains(t, err.Error(), "DataBulkClient does not support a threshold on number of requests")
})
}

View File

@@ -0,0 +1,170 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"context"
"fmt"
"sync"
"time"
elastic "github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/typedapi/core/bulk"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
)
// ReqBulkClient is an Elasticsearch bulk client based on the
// go-elasticsearch/v8/typedapi/code/bulk.Bulk type.
// It supports time- and number-of-requests-based thresholds, but not a
// threshold on the size of the request.
type ReqBulkClient struct {
mut sync.Mutex
indexer *bulk.Bulk
client *elastic.TypedClient
bulkSettings common.BulkSettings
reqTimeout time.Duration
logger mlog.LoggerIFace
quitFlusher chan struct{}
quitFlusherWg sync.WaitGroup
pendingRequests int
}
func NewReqBulkClient(bulkSettings common.BulkSettings,
client *elastic.TypedClient,
reqTimeout time.Duration,
logger mlog.LoggerIFace,
) (*ReqBulkClient, error) {
if bulkSettings.FlushBytes > 0 {
return nil, fmt.Errorf("BulkClientBasic does not support a threshold on bytes")
}
b := &ReqBulkClient{
indexer: client.Bulk(),
client: client,
bulkSettings: bulkSettings,
reqTimeout: reqTimeout,
logger: logger,
quitFlusher: make(chan struct{}),
}
if bulkSettings.FlushInterval > 0 {
b.quitFlusherWg.Add(1)
go b.periodicFlusher()
}
return b, nil
}
// IndexOp is a helper function to add an IndexOperation to the current bulk request.
// doc argument can be a []byte, json.RawMessage or a struct.
func (r *ReqBulkClient) IndexOp(op types.IndexOperation, doc any) error {
r.mut.Lock()
defer r.mut.Unlock()
if err := r.indexer.IndexOp(op, doc); err != nil {
return err
}
return r.flushIfNecessary()
}
// DeleteOp is a helper function to add a DeleteOperation to the current bulk request.
func (r *ReqBulkClient) DeleteOp(op types.DeleteOperation) error {
r.mut.Lock()
defer r.mut.Unlock()
if err := r.indexer.DeleteOp(op); err != nil {
return err
}
return r.flushIfNecessary()
}
// flushIfNecessary flushes the pending buffer if needed.
// It MUST be called with an already acquired mutex.
func (r *ReqBulkClient) flushIfNecessary() error {
r.pendingRequests++
// Check number of requests threshold, only if specified
if r.bulkSettings.FlushNumReqs > 0 {
if r.pendingRequests > r.bulkSettings.FlushNumReqs {
return r._flush()
}
}
return nil
}
func (r *ReqBulkClient) Stop() error {
r.mut.Lock()
r.logger.Info("Stopping Bulk processor")
var flushErr error
if r.pendingRequests > 0 {
flushErr = r._flush()
}
if r.bulkSettings.FlushInterval > 0 {
close(r.quitFlusher)
}
r.mut.Unlock()
// The periodic flusher may already be waiting for mut after its timer
// fires, so release the mutex before waiting for it to exit.
if r.bulkSettings.FlushInterval > 0 {
r.quitFlusherWg.Wait()
}
return flushErr
}
func (r *ReqBulkClient) periodicFlusher() {
defer r.quitFlusherWg.Done()
for {
select {
case <-time.After(r.bulkSettings.FlushInterval):
r.mut.Lock()
if r.pendingRequests > 0 {
if err := r._flush(); err != nil {
r.logger.Warn("Error flushing live indexing buffer", mlog.Err(err))
}
}
r.mut.Unlock()
case <-r.quitFlusher:
return
}
}
}
// _flush MUST be called with an acquired lock.
func (r *ReqBulkClient) _flush() error {
if r.pendingRequests == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), r.reqTimeout)
defer cancel()
_, err := r.indexer.Do(ctx)
if err != nil {
return err
}
r.pendingRequests = 0
return nil
}
func (r *ReqBulkClient) Flush() error {
r.mut.Lock()
defer r.mut.Unlock()
return r._flush()
}

View File

@@ -0,0 +1,366 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"net/http"
"sync"
"testing"
"time"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
)
// setupBulkClient creates a test bulk client with common setup
func setupBulkClient(t *testing.T, flushNumReqs int, flushInterval time.Duration) *ReqBulkClient {
th := api4.SetupEnterprise(t)
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
bulkClient, err := NewReqBulkClient(
common.BulkSettings{
FlushBytes: 0,
FlushInterval: flushInterval,
FlushNumReqs: flushNumReqs,
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log())
require.NoError(t, err)
return bulkClient
}
// createTestPost creates a test post for indexing
func createTestPost(t *testing.T, message string) *common.ESPost {
post, err := common.ESPostFromPost(&model.Post{
Id: model.NewId(),
Message: message,
}, "myteam", "O", true)
require.NoError(t, err)
return post
}
func TestBulkProcessor(t *testing.T) {
th := api4.SetupEnterprise(t)
bulkClient := setupBulkClient(t, *th.App.Config().ElasticsearchSettings.LiveIndexingBatchSize, 0)
post := createTestPost(t, "hello world")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("myindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulkClient.pendingRequests)
err = bulkClient.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulkClient.pendingRequests)
}
func TestIndexOp(t *testing.T) {
bulkClient := setupBulkClient(t, 10, 0)
t.Run("single index operation", func(t *testing.T) {
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulkClient.pendingRequests)
})
t.Run("multiple index operations", func(t *testing.T) {
initialRequests := bulkClient.pendingRequests
for range 5 {
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
}
require.Equal(t, initialRequests+5, bulkClient.pendingRequests)
})
t.Run("auto flush on threshold", func(t *testing.T) {
// Create a new client with low flush threshold
bulkClient2 := setupBulkClient(t, 2, 0)
post1 := createTestPost(t, "first message")
err := bulkClient2.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post1.Id),
}, post1)
require.NoError(t, err)
require.Equal(t, 1, bulkClient2.pendingRequests)
post2 := createTestPost(t, "second message")
err = bulkClient2.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post2.Id),
}, post2)
require.NoError(t, err)
require.Equal(t, 2, bulkClient2.pendingRequests)
// Third operation should trigger flush
post3 := createTestPost(t, "third message")
err = bulkClient2.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post3.Id),
}, post3)
require.NoError(t, err)
require.Equal(t, 0, bulkClient2.pendingRequests)
})
}
func TestDeleteOp(t *testing.T) {
bulkClient := setupBulkClient(t, 10, 0)
t.Run("single delete operation", func(t *testing.T) {
docId := model.NewId()
err := bulkClient.DeleteOp(types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
require.Equal(t, 1, bulkClient.pendingRequests)
})
t.Run("multiple delete operations", func(t *testing.T) {
initialRequests := bulkClient.pendingRequests
for range 3 {
docId := model.NewId()
err := bulkClient.DeleteOp(types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
}
require.Equal(t, initialRequests+3, bulkClient.pendingRequests)
})
t.Run("auto flush on threshold", func(t *testing.T) {
// Create a new client with low flush threshold
bulkClient2 := setupBulkClient(t, 2, 0)
// Add two delete operations
for range 2 {
docId := model.NewId()
err := bulkClient2.DeleteOp(types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
}
require.Equal(t, 2, bulkClient2.pendingRequests)
// Third operation should trigger flush
docId := model.NewId()
err := bulkClient2.DeleteOp(types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
require.Equal(t, 0, bulkClient2.pendingRequests)
})
}
func TestFlush(t *testing.T) {
bulkClient := setupBulkClient(t, 10, 0)
t.Run("flush with pending requests", func(t *testing.T) {
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulkClient.pendingRequests)
err = bulkClient.Flush()
require.NoError(t, err)
require.Equal(t, 0, bulkClient.pendingRequests)
})
t.Run("flush with no pending requests", func(t *testing.T) {
require.Equal(t, 0, bulkClient.pendingRequests)
err := bulkClient.Flush()
require.NoError(t, err)
require.Equal(t, 0, bulkClient.pendingRequests)
})
}
func TestStop(t *testing.T) {
t.Run("stop with pending requests", func(t *testing.T) {
bulkClient := setupBulkClient(t, 10, 0)
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulkClient.pendingRequests)
err = bulkClient.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulkClient.pendingRequests)
})
t.Run("stop with no pending requests", func(t *testing.T) {
bulkClient := setupBulkClient(t, 10, 0)
require.Equal(t, 0, bulkClient.pendingRequests)
err := bulkClient.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulkClient.pendingRequests)
})
t.Run("stop with periodic flusher", func(t *testing.T) {
bulkClient := setupBulkClient(t, 10, 100*time.Millisecond)
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulkClient.pendingRequests)
// Stop should flush pending requests and stop the periodic flusher
err = bulkClient.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulkClient.pendingRequests)
})
}
func TestStopShutsDownPeriodicFlusher(t *testing.T) {
tests := []struct {
name string
status int
wantErr bool
pendingRequests int
}{
{name: "successful pending flush", status: http.StatusOK},
{name: "failed pending flush", status: http.StatusInternalServerError, wantErr: true, pendingRequests: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
const flushInterval = 50 * time.Millisecond
requestStarted := make(chan struct{}, 1)
releaseRequest := make(chan struct{})
var releaseOnce sync.Once
release := func() {
releaseOnce.Do(func() { close(releaseRequest) })
}
t.Cleanup(release)
bulkClient := setupReqBulkClientWithHandler(t, flushInterval, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
select {
case requestStarted <- struct{}{}:
default:
}
<-releaseRequest
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Elastic-Product", "Elasticsearch")
w.WriteHeader(tt.status)
if tt.status == http.StatusOK {
_, _ = w.Write([]byte(`{"errors":false,"items":[]}`))
return
}
_, _ = w.Write([]byte(`{"error":{"type":"test_error","reason":"bulk request failed"},"status":500}`))
}))
post := createTestPost(t, "test message")
err := bulkClient.IndexOp(types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
stopDone := make(chan error, 1)
go func() {
stopDone <- bulkClient.Stop()
}()
select {
case <-requestStarted:
case <-time.After(2 * time.Second):
require.FailNow(t, "timed out waiting for final flush to start")
}
// Stop still holds mut while the final flush is blocked. Waiting for a
// timer interval forces the periodic flusher to contend for that mutex.
time.Sleep(2 * flushInterval)
release()
select {
case err = <-stopDone:
case <-time.After(2 * time.Second):
require.FailNow(t, "Stop deadlocked while waiting for the periodic flusher")
}
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.pendingRequests, bulkClient.pendingRequests)
assertFlusherStopped(t, bulkClient)
})
}
}
func setupReqBulkClientWithHandler(t *testing.T, flushInterval time.Duration, handler http.Handler) *ReqBulkClient {
t.Helper()
client := newTestClient(t, handler)
bulkClient, err := NewReqBulkClient(
common.BulkSettings{
FlushBytes: 0,
FlushInterval: flushInterval,
FlushNumReqs: 10,
},
client,
time.Second,
mlog.CreateConsoleTestLogger(t))
require.NoError(t, err)
return bulkClient
}
func assertFlusherStopped(t *testing.T, bulkClient *ReqBulkClient) {
t.Helper()
select {
case <-bulkClient.quitFlusher:
default:
require.Fail(t, "periodic flusher was not stopped")
}
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"testing"
"time"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/stretchr/testify/require"
)
func TestNewBulk(t *testing.T) {
th := api4.SetupEnterprise(t)
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
t.Run("zeroed bulksettings", func(t *testing.T) {
_, err := NewBulk(
common.BulkSettings{},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log(),
)
require.Error(t, err)
})
t.Run("incompatible bulkSettings", func(t *testing.T) {
_, err := NewBulk(
common.BulkSettings{
FlushBytes: 100,
FlushInterval: 5 * time.Second,
FlushNumReqs: 10,
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log(),
)
require.Error(t, err)
})
t.Run("data-based bulk client", func(t *testing.T) {
client, err := NewBulk(
common.BulkSettings{
FlushBytes: 100,
FlushInterval: 5 * time.Second,
FlushNumReqs: 0,
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log(),
)
require.NoError(t, err)
_, ok := client.(*DataBulkClient)
require.True(t, ok)
})
t.Run("requests-based bulk client", func(t *testing.T) {
client, err := NewBulk(
common.BulkSettings{
FlushBytes: 0,
FlushInterval: 5 * time.Second,
FlushNumReqs: 100,
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log(),
)
require.NoError(t, err)
_, ok := client.(*ReqBulkClient)
require.True(t, ok)
})
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
elastic "github.com/elastic/go-elasticsearch/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func newTestClient(t *testing.T, handler http.Handler) *elastic.TypedClient {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
client, err := elastic.NewTypedClient(elastic.Config{
Addresses: []string{ts.URL},
})
require.NoError(t, err)
return client
}
func infoHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Elastic-Product", "Elasticsearch")
fmt.Fprintf(w, `{"cluster_name":"test","version":{"number":%q,"build_flavor":"default","build_hash":"abc","build_date":"2024-01-01","build_snapshot":false,"build_type":"docker","lucene_version":"9.0.0","minimum_wire_compatibility_version":"7.0.0","minimum_index_compatibility_version":"7.0.0"}}`, version)
}
}
func TestCheckVersion(t *testing.T) {
tests := []struct {
name string
version string
wantVersion string
wantMajor int
wantErrID string
wantUnsupported bool
}{
{
name: "ES 8 is supported",
version: "8.9.0",
wantVersion: "8.9.0",
wantMajor: 8,
},
{
name: "ES 9 is supported",
version: "9.0.0",
wantVersion: "9.0.0",
wantMajor: 9,
},
{
name: "ES 7 is too old, but allowed",
version: "7.17.0",
wantVersion: "7.17.0",
wantMajor: 7,
wantUnsupported: true,
},
{
name: "ES 10 is too new, but allowed",
version: "10.0.0",
wantVersion: "10.0.0",
wantMajor: 10,
wantUnsupported: true,
},
{
name: "invalid version string",
version: "invalid",
wantErrID: "ent.elasticsearch.start.parse_server_version.app_error",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
var buf mlog.Buffer
require.NoError(t, mlog.AddWriterTarget(logger, &buf, true, mlog.LvlError))
client := newTestClient(t, infoHandler(tc.version))
version, major, appErr := checkVersion(context.Background(), client, logger)
require.NoError(t, logger.Flush())
if tc.wantErrID != "" {
require.NotNil(t, appErr)
assert.Equal(t, tc.wantErrID, appErr.Id)
return
}
require.Nil(t, appErr)
assert.Equal(t, tc.wantVersion, version)
assert.Equal(t, tc.wantMajor, major)
if tc.wantUnsupported {
assert.Contains(t, buf.String(), "Unsupported Elasticsearch version")
assert.Contains(t, buf.String(), fmt.Sprintf(`"version":%q`, tc.wantVersion))
assert.Contains(t, buf.String(), `"min_version":8`)
assert.Contains(t, buf.String(), `"max_version":9`)
} else {
assert.Empty(t, buf.String())
}
})
}
}
func TestCheckVersionConnectionError(t *testing.T) {
ts := httptest.NewServer(http.NotFoundHandler())
ts.Close() // close immediately to force connection error
client, err := elastic.NewTypedClient(elastic.Config{
Addresses: []string{ts.URL},
MaxRetries: 0,
})
require.NoError(t, err)
_, _, appErr := checkVersion(context.Background(), client, mlog.CreateConsoleTestLogger(t))
require.NotNil(t, appErr)
assert.Equal(t, "ent.elasticsearch.start.get_server_version.app_error", appErr.Id)
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"crypto/tls"
"net/http"
"time"
"github.com/elastic/go-elasticsearch/v8"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
func createTypedClient(logger mlog.LoggerIFace, cfg *model.Config, fileBackend filestore.FileBackend, debugLogging bool) (*elasticsearch.TypedClient, *model.AppError) {
esCfg, appErr := createClientConfig(logger, cfg, fileBackend, debugLogging)
if appErr != nil {
return nil, appErr
}
client, err := elasticsearch.NewTypedClient(*esCfg)
if err != nil {
return nil, model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.connect_failed", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
}
return client, nil
}
func createUntypedClient(logger mlog.LoggerIFace, cfg *model.Config, fileBackend filestore.FileBackend) (*elasticsearch.Client, *model.AppError) {
esCfg, appErr := createClientConfig(logger, cfg, fileBackend, true)
if appErr != nil {
return nil, appErr
}
client, err := elasticsearch.NewClient(*esCfg)
if err != nil {
return nil, model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.connect_failed", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
}
return client, nil
}
func createClientConfig(logger mlog.LoggerIFace, cfg *model.Config, fileBackend filestore.FileBackend, debugLogging bool) (*elasticsearch.Config, *model.AppError) {
tp := http.DefaultTransport.(*http.Transport).Clone()
tp.TLSClientConfig = &tls.Config{
InsecureSkipVerify: *cfg.ElasticsearchSettings.SkipTLSVerification,
}
esCfg := &elasticsearch.Config{
Addresses: []string{*cfg.ElasticsearchSettings.ConnectionURL},
RetryBackoff: func(i int) time.Duration { return time.Duration(i) * 100 * time.Millisecond }, // A minimal backoff function
RetryOnStatus: []int{502, 503, 504, 429}, // Retry on 429 TooManyRequests statuses
MaxRetries: 3,
DiscoverNodesOnStart: *cfg.ElasticsearchSettings.Sniff,
}
if esCfg.DiscoverNodesOnStart {
esCfg.DiscoverNodesInterval = 30 * time.Second
}
if *cfg.ElasticsearchSettings.ClientCert != "" {
appErr := configureClientCertificate(tp.TLSClientConfig, cfg, fileBackend)
if appErr != nil {
return nil, appErr
}
}
// custom CA
if *cfg.ElasticsearchSettings.CA != "" {
appErr := configureCA(esCfg, cfg, fileBackend)
if appErr != nil {
return nil, appErr
}
}
esCfg.Transport = tp
if *cfg.ElasticsearchSettings.Username != "" {
esCfg.Username = *cfg.ElasticsearchSettings.Username
esCfg.Password = *cfg.ElasticsearchSettings.Password
}
// This is a compatibility mode from previous config settings.
// We have to conditionally enable debug logging due to
// https://github.com/elastic/elastic-transport-go/issues/22
if *cfg.ElasticsearchSettings.Trace == "all" && debugLogging {
esCfg.EnableDebugLogger = true
}
esCfg.Logger = common.NewLogger("Elasticsearch", logger, *cfg.ElasticsearchSettings.Trace == "all")
return esCfg, nil
}
func configureCA(esCfg *elasticsearch.Config, cfg *model.Config, fb filestore.FileBackend) *model.AppError {
// read the certificate authority (CA) file
clientCA, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.CA)
if err != nil {
return model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.ca_cert_missing", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
}
esCfg.CACert = clientCA
return nil
}
func configureClientCertificate(tlsConfig *tls.Config, cfg *model.Config, fb filestore.FileBackend) *model.AppError {
// read the client certificate file
clientCert, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.ClientCert)
if err != nil {
return model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.client_cert_missing", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
}
// read the client key file
clientKey, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.ClientKey)
if err != nil {
return model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.client_key_missing", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
}
// load the client key and certificate
certificate, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
return model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.client_cert_malformed", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
}
// update the TLS config
tlsConfig.Certificates = []tls.Certificate{certificate}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,588 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
elastic "github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks"
)
type ElasticsearchInterfaceTestSuite struct {
common.CommonTestSuite
th *api4.TestHelper
client *elastic.TypedClient
ctx context.Context
fileBackend filestore.FileBackend
}
func TestElasticsearchInterfaceTestSuite(t *testing.T) {
testSuite := &ElasticsearchInterfaceTestSuite{
CommonTestSuite: common.CommonTestSuite{},
}
suite.Run(t, testSuite)
}
func (s *ElasticsearchInterfaceTestSuite) SetupSuite() {
s.th = api4.SetupEnterprise(s.T()).InitBasic(s.T())
s.CommonTestSuite.TH = s.th
s.CommonTestSuite.GetDocumentFn = func(index, documentID string) (bool, json.RawMessage, error) {
resp, err := s.client.API.Get(index, documentID).Do(s.ctx)
if resp == nil {
return false, nil, err
}
return resp.Found, resp.Source_, err
}
s.CommonTestSuite.RefreshIndexFn = func() error {
_, err := s.client.Indices.Refresh().Do(context.Background())
return err
}
s.CommonTestSuite.CreateIndexFn = func(index string) error {
_, err := s.client.Indices.Create(index).Do(s.ctx)
return err
}
s.CommonTestSuite.GetIndexFn = func(indexPattern string) ([]string, error) {
res, err := s.client.Indices.Get(indexPattern).Do(s.ctx)
if err != nil {
return nil, err
}
var names []string
for name := range res {
names = append(names, name)
}
return names, nil
}
// Set up the state for the tests.
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = false
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 1
*cfg.SqlSettings.DisableDatabaseSearch = true
})
s.th.App.Srv().SetLicense(model.NewTestLicense())
if s.fileBackend == nil {
s.fileBackend = &mocks.FileBackend{}
}
// Initialise other stuff for the test.
s.client = createTestClient(s.T(), s.th.Context, s.th.App.Config(), s.th.App.FileBackend())
s.ctx = context.Background()
// Register search engine
s.th.App.SearchEngine().RegisterElasticsearchEngine(&ElasticsearchInterfaceImpl{Platform: s.th.Server.Platform()})
}
func (s *ElasticsearchInterfaceTestSuite) SetupTest() {
if strings.Contains(s.T().Name(), "CJK") {
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = true
})
} else {
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = false
})
}
s.CommonTestSuite.ESImpl = s.th.App.SearchEngine().ElasticsearchEngine
if s.CommonTestSuite.ESImpl.IsActive() {
appErr := s.CommonTestSuite.ESImpl.Stop()
s.Require().Nil(appErr)
}
s.Require().Nil(s.CommonTestSuite.ESImpl.Start(context.Background()))
s.Nil(s.CommonTestSuite.ESImpl.PurgeIndexes(s.th.Context))
s.NoError(s.RefreshIndexFn())
}
func (s *ElasticsearchInterfaceTestSuite) TestSyncBulkIndexChannels() {
s.Run("Should index multiple channels successfully", func() {
// Create test channels
channel1 := &model.Channel{
TeamId: s.th.BasicTeam.Id,
Type: model.ChannelTypeOpen,
Name: "test-channel-1",
DisplayName: "Test Channel 1",
}
channel1.PreSave()
channel2 := &model.Channel{
TeamId: s.th.BasicTeam.Id,
Type: model.ChannelTypePrivate,
Name: "test-channel-2",
DisplayName: "Test Channel 2",
}
channel2.PreSave()
channels := []*model.Channel{channel1, channel2}
// Mock getUserIDsForChannel function
getUserIDsForChannel := func(channel *model.Channel) ([]string, error) {
return []string{s.th.BasicUser.Id, s.th.BasicUser2.Id}, nil
}
teamMemberIDs := []string{s.th.BasicUser.Id, s.th.BasicUser2.Id}
// Test the bulk indexing
appErr := s.CommonTestSuite.ESImpl.SyncBulkIndexChannels(s.th.Context, channels, getUserIDsForChannel, teamMemberIDs)
s.Require().Nil(appErr)
// Refresh the index to ensure data is searchable
s.Require().NoError(s.CommonTestSuite.RefreshIndexFn())
// Verify both channels are indexed
found, _, err := s.CommonTestSuite.GetDocumentFn("channels", channel1.Id)
s.Require().NoError(err)
s.Require().True(found)
found, _, err = s.CommonTestSuite.GetDocumentFn("channels", channel2.Id)
s.Require().NoError(err)
s.Require().True(found)
})
s.Run("Should handle empty channels list", func() {
getUserIDsForChannel := func(channel *model.Channel) ([]string, error) {
return []string{}, nil
}
appErr := s.CommonTestSuite.ESImpl.SyncBulkIndexChannels(s.th.Context, []*model.Channel{}, getUserIDsForChannel, []string{})
s.Require().Nil(appErr)
})
s.Run("Should handle getUserIDsForChannel error", func() {
channel := &model.Channel{
TeamId: s.th.BasicTeam.Id,
Type: model.ChannelTypeOpen,
Name: "test-channel-error",
DisplayName: "Test Channel Error",
}
channel.PreSave()
getUserIDsForChannel := func(channel *model.Channel) ([]string, error) {
return nil, model.NewAppError("TestError", "test.error", nil, "", 500)
}
appErr := s.CommonTestSuite.ESImpl.SyncBulkIndexChannels(s.th.Context, []*model.Channel{channel}, getUserIDsForChannel, []string{})
s.Require().NotNil(appErr)
s.Require().Contains(appErr.Error(), "test.error")
})
}
func (s *ElasticsearchInterfaceTestSuite) TestTemplateCreationClientError() {
s.Run("Should handle error with CausedBy information from elasticsearch", func() {
// Invalid template request that will trigger an error with caused_by
invalidTemplateBody := map[string]any{
"index_patterns": []string{"test-invalid-*"},
"template": map[string]any{
"settings": map[string]any{
"analysis": map[string]any{
"analyzer": map[string]any{
"my_analyzer": map[string]any{
"type": "custom",
"tokenizer": "nonexistent_tokenizer",
},
},
},
},
},
}
templateBytes, err := json.Marshal(invalidTemplateBody)
s.Require().NoError(err)
_, err = s.client.Indices.PutIndexTemplate("test-invalid-template").
Raw(bytes.NewReader(templateBytes)).
Do(s.ctx)
var esErr *types.ElasticsearchError
s.Require().ErrorAs(err, &esErr)
s.Require().NotNil(esErr.ErrorCause.CausedBy, "Expected CausedBy to be present")
s.Require().NotEmpty(esErr.ErrorCause.CausedBy.Type)
s.Require().NotEmpty(*esErr.ErrorCause.CausedBy.Reason)
// clean up after test
_, _ = s.client.Indices.DeleteIndexTemplate("test-invalid-template").Do(s.ctx)
})
}
func TestStartPostsTemplateFailureDoesNotCreateProcessors(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Elastic-Product", "Elasticsearch")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/":
_, _ = w.Write([]byte(`{"name":"test","version":{"number":"8.19.0"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/_cat/plugins":
// Plugin inventory is optional and must not prevent the real template check.
w.WriteHeader(http.StatusInternalServerError)
case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/_index_template/"):
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"type":"illegal_argument_exception","reason":"composable template [posts] template after composition is invalid","caused_by":{"type":"illegal_argument_exception","reason":"Custom Analyzer [mm_lowercaser] failed to find tokenizer under name [icu_tokenizer]"}},"status":400}`))
default:
w.WriteHeader(http.StatusInternalServerError)
}
}))
defer server.Close()
t.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", server.URL)
t.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", model.ElasticsearchSettingsESBackend)
th := api4.SetupEnterprise(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = server.URL
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 10
*cfg.ElasticsearchSettings.EnableIndexing = true
})
es := &ElasticsearchInterfaceImpl{Platform: th.Server.Platform()}
defer func() { require.Nil(t, es.Stop()) }()
appErr := es.Start(context.Background())
require.NotNil(t, appErr)
require.Contains(t, appErr.Error(), "failed to find tokenizer under name [icu_tokenizer]")
require.Contains(t, appErr.Error(), "Verify that the required analysis-icu plugin is installed on every elasticsearch node")
require.Equal(t, int32(0), es.ready.Load())
require.Nil(t, es.bulkProcessor)
require.Nil(t, es.syncBulkProcessor)
}
func TestStartWithoutPluginInventoryUsesTemplateValidation(t *testing.T) {
templateRequested := make(chan string, 4)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Elastic-Product", "Elasticsearch")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/":
_, _ = w.Write([]byte(`{"name":"test","version":{"number":"8.19.0"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/_cat/plugins":
_, _ = w.Write([]byte(`[]`))
case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/_index_template/"):
templateRequested <- strings.TrimPrefix(r.URL.Path, "/_index_template/")
_, _ = w.Write([]byte(`{"acknowledged":true}`))
default:
w.WriteHeader(http.StatusInternalServerError)
}
}))
defer server.Close()
t.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", server.URL)
t.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", model.ElasticsearchSettingsESBackend)
th := api4.SetupEnterprise(t)
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = server.URL
*cfg.ElasticsearchSettings.EnableIndexing = true
})
es := &ElasticsearchInterfaceImpl{Platform: th.Server.Platform()}
defer func() { require.Nil(t, es.Stop()) }()
appErr := es.Start(context.Background())
require.Nil(t, appErr)
require.Equal(t, int32(1), es.ready.Load())
require.Len(t, templateRequested, 4)
requestedTemplates := make([]string, 0, 4)
for range 4 {
requestedTemplates = append(requestedTemplates, <-templateRequested)
}
require.ElementsMatch(t, []string{common.IndexBasePosts, common.IndexBaseChannels, common.IndexBaseUsers, common.IndexBaseFiles}, requestedTemplates)
}
// missingCJKPluginsWarning mirrors the warning Start logs when no CJK analyzer plugin is detected.
const missingCJKPluginsWarning = "EnableCJKAnalyzers is set but no CJK analyzer plugins found installed. Please review elasticsearch settings."
// pluginsHandler serves the endpoints Start and SearchPosts need, reporting one node per given list
// of plugin names and recording the posts template and search request bodies.
func pluginsHandler(t *testing.T, recorder *common.ClusterRecorder, nodePlugins ...[]string) http.HandlerFunc {
info := infoHandler("8.19.0")
catPlugins := common.CatPluginsResponse(nodePlugins...)
readBody := func(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
body, err := io.ReadAll(r.Body)
if !assert.NoError(t, err) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, false
}
return body, true
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Elastic-Product", "Elasticsearch")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/":
info(w, r)
case r.Method == http.MethodGet && r.URL.Path == "/_cat/plugins":
_, _ = fmt.Fprint(w, catPlugins)
case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/_index_template/"):
body, ok := readBody(w, r)
if !ok {
return
}
if strings.HasSuffix(r.URL.Path, common.IndexBasePosts) {
recorder.RecordPostsTemplate(body)
}
_, _ = fmt.Fprint(w, `{"acknowledged":true}`)
case strings.HasSuffix(r.URL.Path, "/_search"):
body, ok := readBody(w, r)
if !ok {
return
}
recorder.RecordSearch(body)
_, _ = fmt.Fprint(w, `{"took":1,"timed_out":false,"hits":{"total":{"value":0,"relation":"eq"},"max_score":0,"hits":[]}}`)
default:
w.WriteHeader(http.StatusInternalServerError)
}
}
}
func setupCJKCluster(t *testing.T, recorder *common.ClusterRecorder, nodePlugins ...[]string) (*api4.TestHelper, *ElasticsearchInterfaceImpl) {
server := httptest.NewServer(pluginsHandler(t, recorder, nodePlugins...))
t.Cleanup(server.Close)
t.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", server.URL)
t.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", model.ElasticsearchSettingsESBackend)
th := api4.SetupEnterprise(t)
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = server.URL
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = true
})
es := &ElasticsearchInterfaceImpl{Platform: th.Server.Platform()}
t.Cleanup(func() { require.Nil(t, es.Stop()) })
return th, es
}
// TestCJKAnalyzersWithPrefixedPluginNames covers a cluster that reports analysis plugins under the
// prefixed component name a managed service assigns to plugins it does not bundle, as AWS
// OpenSearch Service does for the Korean analyzer installed through associate-package.
func TestCJKAnalyzersWithPrefixedPluginNames(t *testing.T) {
namings := []struct {
Name string
Plugins []string
}{
{"only nori prefixed", []string{"analysis-icu", "opensearch-analysis-nori", "analysis-kuromoji", "analysis-smartcn"}},
{"every analysis plugin prefixed", []string{"opensearch-analysis-icu", "opensearch-analysis-nori", "opensearch-analysis-kuromoji", "opensearch-analysis-smartcn"}},
}
for _, naming := range namings {
t.Run(naming.Name, func(t *testing.T) {
recorder := &common.ClusterRecorder{}
th, es := setupCJKCluster(t, recorder, naming.Plugins)
require.Nil(t, es.Start(context.Background()))
expectedSubFields := map[string]string{
"nori": "mm_nori",
"kuromoji": "mm_kuromoji",
"smartcn": "mm_smartcn",
}
t.Run("the posts index template maps every CJK sub-field", func(t *testing.T) {
template := recorder.PostsTemplate()
require.NotEmpty(t, template, "no posts index template was created")
require.Equal(t, expectedSubFields, common.TemplatePropertyFields(t, template, "message"))
require.Equal(t, expectedSubFields, common.TemplatePropertyFields(t, template, "attachments"))
require.Subset(t, common.TemplateAnalyzers(t, template), []string{"mm_nori", "mm_kuromoji", "mm_smartcn"})
})
t.Run("no missing plugin warning is logged", func(t *testing.T) {
require.NoError(t, th.TestLogger.Flush())
testlib.AssertNoLog(t, th.LogBuffer, mlog.LvlWarn.Name, missingCJKPluginsWarning)
})
channels := model.ChannelList{{Id: model.NewId(), TeamId: model.NewId(), Type: model.ChannelTypeOpen}}
searchFields := func(t *testing.T, terms string) [][]string {
t.Helper()
recorder.ResetSearches()
_, _, appErr := es.SearchPosts(channels, model.ParseSearchParams(terms, 0), 0, 20)
require.Nil(t, appErr)
bodies := recorder.SearchBodies()
require.Len(t, bodies, 1)
return common.SimpleQueryStringFields(t, bodies[0])
}
t.Run("a CJK query searches the CJK sub-fields", func(t *testing.T) {
fields := searchFields(t, "검색")
require.Contains(t, fields, []string{"message", "message.nori", "message.kuromoji", "message.smartcn"})
require.Contains(t, fields, []string{"attachments", "attachments.nori", "attachments.kuromoji", "attachments.smartcn"})
})
t.Run("a non-CJK query only searches the base fields", func(t *testing.T) {
common.RequireOnlyBaseFields(t, searchFields(t, "search"))
})
t.Run("a CJK query only searches the base fields when CJK analyzers are disabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = false
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = true
})
common.RequireOnlyBaseFields(t, searchFields(t, "검색"))
})
})
}
}
// TestCJKAnalyzersWithoutAnyCJKPlugin covers the diagnostic that made this failure mode hard to
// spot: the warning only fires when no CJK analyzer plugin is detected at all.
func TestCJKAnalyzersWithoutAnyCJKPlugin(t *testing.T) {
recorder := &common.ClusterRecorder{}
th, es := setupCJKCluster(t, recorder, []string{"analysis-icu"})
require.Nil(t, es.Start(context.Background()))
template := recorder.PostsTemplate()
require.NotEmpty(t, template, "no posts index template was created")
require.Empty(t, common.TemplatePropertyFields(t, template, "message"))
channels := model.ChannelList{{Id: model.NewId(), TeamId: model.NewId(), Type: model.ChannelTypeOpen}}
_, _, appErr := es.SearchPosts(channels, model.ParseSearchParams("검색", 0), 0, 20)
require.Nil(t, appErr)
bodies := recorder.SearchBodies()
require.Len(t, bodies, 1)
common.RequireOnlyBaseFields(t, common.SimpleQueryStringFields(t, bodies[0]))
require.NoError(t, th.TestLogger.Flush())
testlib.AssertLog(t, th.LogBuffer, mlog.LvlWarn.Name, missingCJKPluginsWarning)
}
func TestWrapElasticsearchTemplateError(t *testing.T) {
nestedReason := "Custom Analyzer [mm_lowercaser] failed to find tokenizer under name [icu_tokenizer]"
nested := &types.ErrorCause{Type: "illegal_argument_exception", Reason: &nestedReason}
outerReason := "failed to build posts template"
stackTrace := "stack trace"
original := &types.ElasticsearchError{
ErrorCause: types.ErrorCause{
Type: "illegal_argument_exception",
Reason: &outerReason,
CausedBy: nested,
StackTrace: &stackTrace,
Metadata: map[string]json.RawMessage{
"arbitrary": json.RawMessage(`"metadata"`),
},
},
Status: 400,
}
wrapped := fmt.Errorf("request context: %w", original)
formatted := wrapElasticsearchTemplateError(wrapped)
require.Contains(t, formatted.Error(), "status: 400, failed: [illegal_argument_exception], reason: failed to build posts template")
require.Contains(t, formatted.Error(), "caused by: [illegal_argument_exception] Custom Analyzer [mm_lowercaser] failed to find tokenizer under name [icu_tokenizer]")
require.NotContains(t, formatted.Error(), "stack trace")
require.NotContains(t, formatted.Error(), "arbitrary metadata")
require.ErrorIs(t, formatted, wrapped)
var extracted *types.ElasticsearchError
require.True(t, errors.As(formatted, &extracted))
require.Same(t, original, extracted)
generic := errors.New("template request failed")
require.Same(t, generic, wrapElasticsearchTemplateError(generic))
require.NoError(t, wrapElasticsearchTemplateError(nil))
}
type testBulkClient struct {
stopCalls int
stopErr error
}
func (b *testBulkClient) IndexOp(types.IndexOperation, any) error {
return nil
}
func (b *testBulkClient) DeleteOp(types.DeleteOperation) error {
return nil
}
func (b *testBulkClient) Flush() error {
return nil
}
func (b *testBulkClient) Stop() error {
b.stopCalls++
return b.stopErr
}
func TestElasticsearchStopCleansUpProcessors(t *testing.T) {
tests := []struct {
name string
ready bool
firstError bool
}{
{name: "ready", ready: true},
{name: "ready first stop error", ready: true, firstError: true},
{name: "partially initialized", ready: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bulk := &testBulkClient{}
syncBulk := &testBulkClient{}
if tt.firstError {
bulk.stopErr = errors.New("bulk stop failed")
}
es := &ElasticsearchInterfaceImpl{
client: &elastic.TypedClient{},
bulkProcessor: bulk,
syncBulkProcessor: syncBulk,
}
if tt.ready {
es.ready.Store(1)
es.healthy.Store(1)
}
require.Nil(t, es.Stop())
require.Equal(t, int32(0), es.ready.Load())
require.Equal(t, int32(0), es.healthy.Load())
require.Nil(t, es.client)
require.Nil(t, es.bulkProcessor)
require.Nil(t, es.syncBulkProcessor)
require.Equal(t, 1, bulk.stopCalls)
require.Equal(t, 1, syncBulk.stopCalls)
require.Nil(t, es.Stop())
require.Equal(t, 1, bulk.stopCalls)
require.Equal(t, 1, syncBulk.stopCalls)
})
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"context"
"io"
"net/http"
"sync/atomic"
"time"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/elastic/go-elasticsearch/v8/esutil"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type ElasticsearchIndexerInterfaceImpl struct {
Server *app.Server
bulkProcessor esutil.BulkIndexer
}
func (esi *ElasticsearchIndexerInterfaceImpl) MakeWorker() model.Worker {
const workerName = "EnterpriseElasticsearchIndexer"
// Initializing logger
logger := esi.Server.Jobs.Logger().With(mlog.String("worker_name", workerName))
// Creating the client
client, appErr := createUntypedClient(logger, esi.Server.Jobs.Config(), esi.Server.Platform().FileBackend())
if appErr != nil {
logger.Error("Worker: Failed to Create Client", mlog.Err(appErr))
return nil
}
var realFailed atomic.Int64
return common.NewIndexerWorker(workerName, model.ElasticsearchSettingsESBackend,
esi.Server.Jobs,
logger,
esi.Server.Platform().FileBackend(), esi.Server.License,
func() error {
// Creating the bulk indexer from the client.
biCfg := esutil.BulkIndexerConfig{
Client: client,
OnError: func(_ context.Context, err error) {
logger.Error("Error from elasticsearch bulk indexer", mlog.Err(err))
},
Timeout: time.Duration(*esi.Server.Jobs.Config().ElasticsearchSettings.RequestTimeoutSeconds) * time.Second,
NumWorkers: common.NumIndexWorkers(),
}
if *esi.Server.Jobs.Config().ElasticsearchSettings.Trace == "all" {
biCfg.DebugLogger = common.NewBulkIndexerLogger(logger, workerName)
}
var err error
esi.bulkProcessor, err = esutil.NewBulkIndexer(biCfg)
return err
},
// Function to add an item in the bulk processor
func(indexName, indexOp, docID string, body io.ReadSeeker) error {
return esi.bulkProcessor.Add(context.Background(), esutil.BulkIndexerItem{
Index: indexName,
Action: indexOp,
DocumentID: docID,
Body: body,
OnFailure: func(_ context.Context, item esutil.BulkIndexerItem, resp esutil.BulkIndexerResponseItem, err error) {
if item.Action == "delete" && resp.Status == http.StatusNotFound {
return
}
realFailed.Add(1)
fields := []mlog.Field{
mlog.String("index", item.Index),
mlog.String("doc_id", item.DocumentID),
mlog.String("action", item.Action),
mlog.Int("status", resp.Status),
}
if resp.Error.Type != "" || resp.Error.Reason != "" {
fields = append(fields,
mlog.String("error_type", resp.Error.Type),
mlog.String("error_reason", resp.Error.Reason),
)
}
if err != nil {
fields = append(fields, mlog.Err(err))
}
logger.Warn("Bulk indexer: failed to index document", fields...)
},
})
},
// Closing the bulk processor and returning the number of failed items.
func() (int64, error) {
err := esi.bulkProcessor.Close(context.Background())
stats := esi.bulkProcessor.Stats()
fields := []mlog.Field{
mlog.Uint("num_indexed", stats.NumIndexed),
mlog.Int("num_failed", realFailed.Load()),
mlog.Uint("stats_num_failed", stats.NumFailed),
mlog.Uint("num_added", stats.NumAdded),
mlog.Uint("num_flushed", stats.NumFlushed),
}
if err != nil {
logger.Warn("Bulk indexer closed with error", append(fields, mlog.Err(err))...)
} else {
logger.Info("Bulk indexer closed", fields...)
}
return realFailed.Load(), err
})
}

View File

@@ -0,0 +1,230 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
)
func TestElasticSearchIndexerJobIsEnabled(t *testing.T) {
t.Run("ElasticSearch feature is enabled then job is enabled", func(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t)
th.Server.SetLicense(model.NewTestLicense("elastic_search"))
esImpl := &ElasticsearchIndexerInterfaceImpl{
Server: th.Server,
}
worker := esImpl.MakeWorker()
config := &model.Config{
ElasticsearchSettings: model.ElasticsearchSettings{
EnableIndexing: new(true),
},
}
result := worker.IsEnabled(config)
assert.Equal(t, result, true)
})
t.Run("there is NO license then job is disabled", func(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t)
th.Server.SetLicense(nil)
esImpl := &ElasticsearchIndexerInterfaceImpl{
Server: th.Server,
}
worker := esImpl.MakeWorker()
config := &model.Config{
ElasticsearchSettings: model.ElasticsearchSettings{
EnableIndexing: new(true),
},
}
result := worker.IsEnabled(config)
assert.Equal(t, result, false)
})
}
// TestElasticsearchDeleteNonExistentDocument verifies that bulk deletes returning
// 404 (document not found) do not cause the indexing job to fail. A soft-deleted
// post that was never indexed is used so the delete operation is guaranteed to 404.
//
// Requires a running Elasticsearch instance (skipped otherwise).
func TestElasticsearchDeleteNonExistentDocument(t *testing.T) {
th := api4.SetupEnterprise(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.SqlSettings.DisableDatabaseSearch = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
ctx := context.Background()
_, err := client.Info().Do(ctx)
if err != nil {
t.Skipf("Elasticsearch not available at %s: %v", *th.App.Config().ElasticsearchSettings.ConnectionURL, err)
}
// Soft-delete a post so the job issues a bulk delete for it. Since the index
// is empty (no prior reindex), the document doesn't exist and ES returns 404.
_, appErr := th.App.DeletePost(th.Context, th.BasicPost.Id, th.BasicUser.Id)
require.Nil(t, appErr)
impl := ElasticsearchIndexerInterfaceImpl{Server: th.App.Srv()}
worker := impl.MakeWorker()
require.NotNil(t, worker)
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, worker, nil)
go worker.Run()
defer worker.Stop()
job, appErr := th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
job = waitForElasticsearchJob(t, th, job.Id, 60*time.Second)
assert.Equal(t, model.JobStatusSuccess, job.Status, "reindex with 404 deletes should succeed")
}
// TestElasticsearchIndexerBulkWriteFailures verifies that a reindex job is marked
// as failed when Elasticsearch rejects bulk writes with per-item errors (e.g. a
// write block on the index), rather than silently reporting success.
//
// Requires a running Elasticsearch instance (skipped otherwise).
func TestElasticsearchIndexerBulkWriteFailures(t *testing.T) {
th := api4.SetupEnterprise(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.SqlSettings.DisableDatabaseSearch = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
ctx := context.Background()
// Skip if Elasticsearch is not reachable.
_, err := client.Info().Do(ctx)
if err != nil {
t.Skipf("Elasticsearch not available at %s: %v", *th.App.Config().ElasticsearchSettings.ConnectionURL, err)
}
impl := ElasticsearchIndexerInterfaceImpl{Server: th.App.Srv()}
worker := impl.MakeWorker()
require.NotNil(t, worker)
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, worker, nil)
go worker.Run()
defer worker.Stop()
// Phase 1: clean reindex so the indices exist.
job, appErr := th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
job = waitForElasticsearchJob(t, th, job.Id, 60*time.Second)
require.Equal(t, model.JobStatusSuccess, job.Status, "initial reindex should succeed")
// Collect the indices that were created (skip internal dot-prefixed ones).
indexMap, err := client.Indices.Get("*").Do(ctx)
require.NoError(t, err)
var indexNames []string
for name := range indexMap {
if !strings.HasPrefix(name, ".") {
indexNames = append(indexNames, name)
}
}
require.NotEmpty(t, indexNames, "initial reindex should have created at least one index")
// Apply a write block to all Mattermost indices and remove it on cleanup.
setWriteBlock := func(blocked bool) {
body := `{"index.blocks.write":true}`
if !blocked {
body = `{"index.blocks.write":false}`
}
_, putErr := client.Indices.PutSettings().
Indices(strings.Join(indexNames, ",")).
Raw(strings.NewReader(body)).
Do(ctx)
require.NoError(t, putErr, "failed to set write block=%v", blocked)
}
setWriteBlock(true)
defer setWriteBlock(false)
// Phase 2: reindex against write-blocked indices — job must be marked as error.
job, appErr = th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
job = waitForElasticsearchJob(t, th, job.Id, 60*time.Second)
assert.Equal(t, model.JobStatusError, job.Status, "reindex against write-blocked indices should fail")
}
// waitForElasticsearchJob polls the job store until the job reaches a terminal
// status or the timeout expires.
func waitForElasticsearchJob(t *testing.T, th *api4.TestHelper, jobID string, timeout time.Duration) *model.Job {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
job, err := th.App.Srv().Store().Job().Get(th.Context, jobID)
require.NoError(t, err)
switch job.Status {
case model.JobStatusSuccess, model.JobStatusError, model.JobStatusCanceled:
return job
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("job %s did not reach a terminal status within %s", jobID, timeout)
return nil
}
func TestElasticSearchIndexerPending(t *testing.T) {
th := api4.SetupEnterprise(t).InitBasic(t)
// Set up the state for the tests.
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.SqlSettings.DisableDatabaseSearch = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
impl := ElasticsearchIndexerInterfaceImpl{
Server: th.App.Srv(),
}
worker := impl.MakeWorker()
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, worker, nil)
go worker.Run()
job, appErr := th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
worker.Stop()
job, err := th.App.Srv().Store().Job().Get(th.Context, job.Id)
require.NoError(t, err)
assert.Equal(t, job.Status, model.JobStatusPending)
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
mainHelper = testlib.NewMainHelper()
defer mainHelper.Close()
api4.SetMainHelper(mainHelper)
mainHelper.Main(m)
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"time"
elastic "github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/esutil"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
)
type SyncBulk struct {
client *elastic.TypedClient
bulkIndexer esutil.BulkIndexer
}
func NewSyncBulk(client *elastic.TypedClient) (*SyncBulk, error) {
bulkIndexer, err := newBulkIndexer(client)
if err != nil {
return nil, err
}
return &SyncBulk{client, bulkIndexer}, nil
}
func newBulkIndexer(client *elastic.TypedClient) (esutil.BulkIndexer, error) {
return esutil.NewBulkIndexer(esutil.BulkIndexerConfig{
Client: client, // The Elasticsearch client
FlushBytes: common.BulkFlushBytes, // The flush threshold in bytes
FlushInterval: 30 * time.Second, // The periodic flush interval
})
}
// IndexOp is a helper function to add an IndexOperation to the current bulk request.
// doc argument can be a []byte, json.RawMessage or a struct.
func (r *SyncBulk) IndexOp(op types.IndexOperation, doc any) error {
var body io.ReadSeeker
switch v := doc.(type) {
case []byte:
body = bytes.NewReader(v)
case json.RawMessage:
body = bytes.NewReader(v)
default:
data, err := json.Marshal(doc)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
return r.bulkIndexer.Add(context.Background(), esutil.BulkIndexerItem{
Index: *op.Index_,
Action: "index",
DocumentID: *op.Id_,
Body: body,
})
}
// DeleteOp is a helper function to add a DeleteOperation to the current bulk request.
func (r *SyncBulk) DeleteOp(op types.DeleteOperation) error {
return r.bulkIndexer.Add(context.Background(), esutil.BulkIndexerItem{
Index: *op.Index_,
Action: "delete",
DocumentID: *op.Id_,
})
}
func (r *SyncBulk) Stop() error {
return r.bulkIndexer.Close(context.Background())
}
func (r *SyncBulk) Flush() error {
// Flush by closing the indexer: there is no manual Flush method
if err := r.bulkIndexer.Close(context.Background()); err != nil {
return err
}
// Restart the indexer so that we can keep using it
bulkIndexer, err := newBulkIndexer(r.client)
if err != nil {
return fmt.Errorf("unable to restart bulk indexer: %w", err)
}
r.bulkIndexer = bulkIndexer
return nil
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"testing"
"github.com/elastic/go-elasticsearch/v8"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks"
)
func createTestClient(t *testing.T, rctx request.CTX, cfg *model.Config, fileStore filestore.FileBackend) *elasticsearch.TypedClient {
t.Helper()
if fileStore == nil {
fileStore = &mocks.FileBackend{}
}
client, err := createTypedClient(rctx.Logger(), cfg, fileStore, true)
require.Nil(t, err)
return client
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package elasticsearch
import (
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/elasticsearch"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/opensearch"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/app/platform"
ejobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
)
func init() {
platform.RegisterElasticsearchInterface(func(s *platform.PlatformService) searchengine.SearchEngineInterface {
if *s.Config().ElasticsearchSettings.Backend == model.ElasticsearchSettingsESBackend {
return &elasticsearch.ElasticsearchInterfaceImpl{Platform: s}
}
return &opensearch.OpensearchInterfaceImpl{Platform: s}
})
app.RegisterJobsElasticsearchIndexerInterface(func(s *app.Server) ejobs.IndexerJobInterface {
if *s.Config().ElasticsearchSettings.Backend == model.ElasticsearchSettingsESBackend {
return &elasticsearch.ElasticsearchIndexerInterfaceImpl{Server: s}
}
return &opensearch.OpensearchIndexerInterfaceImpl{Server: s}
})
app.RegisterJobsElasticsearchAggregatorInterface(func(s *app.Server) ejobs.ElasticsearchAggregatorInterface {
if *s.Config().ElasticsearchSettings.Backend == model.ElasticsearchSettingsESBackend {
return &elasticsearch.ElasticsearchAggregatorInterfaceImpl{Server: s}
}
return &opensearch.OpensearchAggregatorInterfaceImpl{Server: s}
})
}

View File

@@ -0,0 +1,330 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"context"
"errors"
"net/http"
"strconv"
"sync"
"time"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
const (
aggregatorJobPollingInterval = 15 * time.Second
indexDeletionBatchSize = 20
)
type OpensearchAggregatorInterfaceImpl struct {
Server *app.Server
}
type OpensearchAggregatorWorker struct {
name string
// stateMut protects stopCh and stopped and helps enforce
// ordering in case subsequent Run or Stop calls are made.
stateMut sync.Mutex
stopCh chan struct{}
stopped bool
stoppedCh chan bool
jobs chan model.Job
jobServer *jobs.JobServer
logger mlog.LoggerIFace
fileBackend filestore.FileBackend
client *opensearchapi.Client
license func() *model.License
}
func (esi *OpensearchAggregatorInterfaceImpl) MakeWorker() model.Worker {
const workerName = "EnterpriseOpensearchAggregator"
worker := OpensearchAggregatorWorker{
name: workerName,
stoppedCh: make(chan bool, 1),
jobs: make(chan model.Job),
jobServer: esi.Server.Jobs,
logger: esi.Server.Jobs.Logger().With(mlog.String("worker_name", workerName)),
fileBackend: esi.Server.Platform().FileBackend(),
license: esi.Server.License,
stopped: true,
}
return &worker
}
func (worker *OpensearchAggregatorWorker) Run() {
worker.stateMut.Lock()
// We have to re-assign the stop channel again, because
// it might happen that the job was restarted due to a config change.
if worker.stopped {
worker.stopped = false
worker.stopCh = make(chan struct{})
} else {
worker.stateMut.Unlock()
return
}
// Run is called from a separate goroutine and doesn't return.
// So we cannot Unlock in a defer clause.
worker.stateMut.Unlock()
worker.logger.Debug("Worker Started")
defer func() {
worker.logger.Debug("Worker Finished")
worker.stoppedCh <- true
}()
client, err := createClient(worker.logger, worker.jobServer.Config(), worker.fileBackend, false)
if err != nil {
worker.logger.Error("Worker Failed to Create Client", mlog.Err(err))
return
}
worker.client = client
for {
select {
case <-worker.stopCh:
worker.logger.Debug("Worker Received stop signal")
return
case job := <-worker.jobs:
worker.DoJob(&job)
}
}
}
func (worker *OpensearchAggregatorWorker) IsEnabled(cfg *model.Config) bool {
if license := worker.license(); license == nil || !*license.Features.Elasticsearch {
return false
}
if *cfg.ElasticsearchSettings.EnableIndexing {
return true
}
return false
}
func (worker *OpensearchAggregatorWorker) Stop() {
worker.stateMut.Lock()
defer worker.stateMut.Unlock()
// Set to close, and if already closed before, then return.
if worker.stopped {
return
}
worker.stopped = true
worker.logger.Debug("Worker Stopping")
close(worker.stopCh)
<-worker.stoppedCh
}
func (worker *OpensearchAggregatorWorker) JobChannel() chan<- model.Job {
return worker.jobs
}
func (worker *OpensearchAggregatorWorker) DoJob(job *model.Job) {
logger := worker.logger.With(jobs.JobLoggerFields(job)...)
logger.Debug("Worker: Received a new candidate job.")
defer worker.jobServer.HandleJobPanic(logger, job)
var appErr *model.AppError
job, appErr = worker.jobServer.ClaimJob(job)
if appErr != nil {
logger.Warn("Worker: Error occurred while trying to claim job", mlog.Err(appErr))
return
} else if job == nil {
return
}
logger.Info("Worker: Aggregation job claimed by worker")
var cancelContext request.CTX = request.EmptyContext(worker.logger)
cancelCtx, cancelCancelWatcher := context.WithCancel(context.Background())
cancelWatcherChan := make(chan struct{}, 1)
cancelContext = cancelContext.WithContext(cancelCtx)
go worker.jobServer.CancellationWatcher(cancelContext, job.Id, cancelWatcherChan)
defer cancelCancelWatcher()
rctx := request.EmptyContext(worker.logger)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
cutoff := today.AddDate(0, 0, -*worker.jobServer.Config().ElasticsearchSettings.AggregatePostsAfterDays+1)
// Get all the daily Elasticsearch post indexes to work out which days aren't aggregated yet.
dateFormat := *worker.jobServer.Config().ElasticsearchSettings.IndexPrefix + common.IndexBasePosts + "_2006_01_02"
datedIndexes := []time.Time{}
postIndexesResult, err := worker.client.Indices.Get(rctx.Context(), opensearchapi.IndicesGetReq{
Indices: []string{*worker.jobServer.Config().ElasticsearchSettings.IndexPrefix + common.IndexBasePosts + "_*"},
})
if err != nil {
appError := model.NewAppError("OpensearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.get_indexes.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
worker.setJobError(logger, job, appError)
return
}
for index := range *postIndexesResult.IndicesGetRespData {
var indexDate time.Time
indexDate, err = time.Parse(dateFormat, index)
if err != nil {
logger.Warn("Failed to parse date from posts index. Ignoring index.", mlog.String("index", index))
} else {
datedIndexes = append(datedIndexes, indexDate)
}
}
// Work out how far back the reindexing (and index deletion) needs to go.
var oldestDay time.Time
oldestDayFound := false
indexesToPurge := []string{}
for _, date := range datedIndexes {
if date.Before(cutoff) {
logger.Debug("Worker: Post index identified for purging", mlog.Time("date", date))
indexesToPurge = append(indexesToPurge, date.Format(dateFormat))
if !oldestDayFound || oldestDay.After(date) {
oldestDay = date
oldestDayFound = true
}
} else {
logger.Debug("Worker: Post index is within the range to keep", mlog.Time("date", date))
}
}
if !oldestDayFound {
// Nothing to purge.
logger.Info("Worker: Aggregation job completed. Nothing to aggregate.")
worker.setJobSuccess(logger, job)
return
}
// Trigger a reindexing job with the appropriate dates.
reindexingStartDate := oldestDay
reindexingEndDate := cutoff
logger.Info("Worker: Aggregation job reindexing", mlog.String("start_date", reindexingStartDate.Format("2006-01-02")), mlog.String("end_date", reindexingEndDate.Format("2006-01-02")))
var indexJob *model.Job
if indexJob, appErr = worker.jobServer.CreateJob(
rctx,
model.JobTypeElasticsearchPostIndexing,
map[string]string{
"start_time": strconv.FormatInt(reindexingStartDate.UnixNano()/int64(time.Millisecond), 10),
"end_time": strconv.FormatInt(reindexingEndDate.UnixNano()/int64(time.Millisecond), 10),
},
); appErr != nil {
logger.Error("Worker: Failed to create indexing job.", mlog.Err(appErr))
appError := model.NewAppError("OpensearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.create_index_job.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(appErr)
worker.setJobError(logger, job, appError)
return
}
for {
select {
case <-cancelWatcherChan:
logger.Info("Worker: Aggregation job has been canceled via CancellationWatcher")
worker.setJobCanceled(logger, job)
return
case <-worker.stopCh:
logger.Info("Worker: Aggregation job has been canceled via Worker Stop")
worker.setJobCanceled(logger, job)
return
case <-time.After(aggregatorJobPollingInterval):
// Get the details of the indexing job we are waiting on.
indexJob, err = worker.jobServer.Store.Job().Get(rctx, indexJob.Id)
if err != nil {
var appErr *model.AppError
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
appErr = model.NewAppError("DoJob", "app.job.get.app_error", nil, "", http.StatusNotFound).Wrap(nfErr)
default:
appErr = model.NewAppError("DoJob", "app.job.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
worker.setJobError(logger, job, appErr)
return
}
// Wait for the aggregation job to finish.
// On success, we delete the old indexes.
// Otherwise, fail the job.
switch indexJob.Status {
case model.JobStatusSuccess:
// We limit the number of indexes to delete at one shot.
// A minor side-effect of this is that the aggregation job status
// will be redundantly queried multiple times, but that's not a major bottleneck.
curWindow := indexesToPurge
deleteMore := false
if len(indexesToPurge) > indexDeletionBatchSize {
curWindow = indexesToPurge[:indexDeletionBatchSize]
indexesToPurge = indexesToPurge[indexDeletionBatchSize:]
deleteMore = true
}
// Delete indexes
if _, err = worker.client.Indices.Delete(rctx.Context(), opensearchapi.IndicesDeleteReq{
Indices: curWindow,
}); err != nil {
appError := model.NewAppError("OpensearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.delete_indexes.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
logger.Error("Worker: Failed to delete indexes for job", mlog.String("workername", worker.name), mlog.String("job_id", job.Id), mlog.Err(appError))
worker.setJobError(logger, job, appError)
return
}
if !deleteMore {
// Job done. Set the status to success.
logger.Info("Worker: Aggregation job finished successfully")
worker.setJobSuccess(logger, job)
return
}
case model.JobStatusPending, model.JobStatusInProgress:
// Indexing job is in progress or pending. Update the progress of this job.
if err := worker.jobServer.SetJobProgress(job, indexJob.Progress); err != nil {
logger.Error("Worker: Failed to set progress for job", mlog.Err(err))
worker.setJobError(logger, job, err)
return
}
default:
// error case
appError := model.NewAppError("OpensearchAggregatorWorker", "ent.elasticsearch.aggregator_worker.index_job_failed.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError)
logger.Error("Worker: Index aggregation job failed", mlog.Err(appError))
worker.setJobError(logger, job, appError)
return
}
}
}
}
func (worker *OpensearchAggregatorWorker) setJobSuccess(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobSuccess(job); err != nil {
logger.Error("Worker: Failed to set success for job", mlog.Err(err))
worker.setJobError(logger, job, err)
}
}
func (worker *OpensearchAggregatorWorker) setJobError(logger mlog.LoggerIFace, job *model.Job, appError *model.AppError) {
if err := worker.jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err))
}
}
func (worker *OpensearchAggregatorWorker) setJobCanceled(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobCanceled(job); err != nil {
logger.Error("Worker: Failed to mark job as canceled", mlog.Err(err))
}
}

View File

@@ -0,0 +1,224 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"bytes"
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
)
func TestElasticsearchAggregation(t *testing.T) {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://opensearch:9201")
os.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", "opensearch")
}
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
th := api4.SetupEnterpriseWithStoreMock(t)
rctx := request.TestContext(t)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockUserStore.On("GetAllProfiles", mock.Anything).Return(nil, nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockJobStore := mocks.JobStore{}
mockJobStore.On("Save", mock.AnythingOfType("*model.Job")).Return(&model.Job{}, nil)
mockJobStore.On("UpdateStatus", mock.AnythingOfType("string"), model.JobStatusSuccess).Return(&model.Job{}, nil)
mockJobStore.On("Get", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).Return(&model.Job{
Status: model.JobStatusSuccess,
}, nil)
mockJobStore.On("UpdateStatusOptimistically",
mock.AnythingOfType("string"),
model.JobStatusPending,
model.JobStatusInProgress).
Return(&model.Job{}, nil)
mockJobStore.On("GetAllByType", mock.AnythingOfType("string")).Return([]*model.Job{{
Id: "abcxyz123",
Type: "EnterpriseElasticsearchIndexer",
Status: model.JobStatusCanceled,
}}, nil)
mockStore := th.App.Srv().Platform().Store.(*mocks.Store)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("Job").Return(&mockJobStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
aggImpl := OpensearchAggregatorInterfaceImpl{Server: th.Server}
// Register search engine
th.App.SearchEngine().RegisterElasticsearchEngine(&OpensearchInterfaceImpl{
Platform: th.Server.Platform(),
})
// Set up the state for the tests.
th.App.UpdateConfig(func(cfg *model.Config) {
if os.Getenv("IS_CI") == "true" {
*cfg.ElasticsearchSettings.ConnectionURL = "http://opensearch:9201"
} else {
*cfg.ElasticsearchSettings.ConnectionURL = "http://localhost:9201"
}
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 1
*cfg.ElasticsearchSettings.AggregatePostsAfterDays = 1
*cfg.SqlSettings.DisableDatabaseSearch = true
})
esImpl := th.App.SearchEngine().ElasticsearchEngine
appErr := esImpl.Start(context.Background())
if appErr != nil && appErr.Id != "ent.elasticsearch.start.already_started.app_error" {
require.Fail(t, "failed to start elasticsearch", appErr)
}
require.Nil(t, esImpl.PurgeIndexes(rctx))
post := &model.Post{
Id: model.NewId(),
ChannelId: "channel",
Message: "hi",
}
for i := range indexDeletionBatchSize + 1 {
indexPost(t, th, esImpl.(*OpensearchInterfaceImpl),
post,
time.Now().Add(-time.Duration(4+i)*24*time.Hour))
}
job := &model.Job{
Id: model.NewId(),
Type: model.JobTypeElasticsearchPostAggregation,
Status: model.JobStatusPending,
}
_, err := th.Server.Store().Job().Save(job)
require.NoError(t, err)
worker := aggImpl.MakeWorker().(*OpensearchAggregatorWorker)
worker.client = createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
worker.jobServer.Store = mockStore
indexingImpl := OpensearchIndexerInterfaceImpl{
Server: th.App.Srv(),
}
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, indexingImpl.MakeWorker(), nil)
worker.DoJob(job)
// We assert the minimum number of calls to verify that
// batching is working correctly. Because job().Get() will happen
// in each iteration.
numCalls := 0
for _, call := range mockJobStore.Calls {
if call.Method == "Get" {
numCalls++
}
}
assert.GreaterOrEqual(t, numCalls, 8, "Unexpected number of Jobstore.Get calls")
}
func TestElasticsearchAggregationSkipDuringBulkIndexing(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockJobStore := mocks.JobStore{}
mockStore := th.App.Srv().Platform().Store.(*mocks.Store)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("Job").Return(&mockJobStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
aggImpl := OpensearchAggregatorInterfaceImpl{Server: th.Server}
aggImpl.Server.Jobs.Store = mockStore
// Register search engine
th.App.SearchEngine().RegisterElasticsearchEngine(&OpensearchInterfaceImpl{
Platform: th.Server.Platform(),
})
// Set up the state for the tests.
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 1
*cfg.ElasticsearchSettings.AggregatePostsAfterDays = 1
*cfg.SqlSettings.DisableDatabaseSearch = true
})
sched := aggImpl.MakeScheduler()
// Pass pending jobs as true
job, appErr := sched.ScheduleJob(th.Context, th.App.Config(), true, nil)
require.Nil(t, job)
require.Nil(t, appErr)
mockJobStore.AssertNotCalled(t, "GetCountByStatusAndType")
}
func indexPost(t *testing.T, th *api4.TestHelper, esImpl *OpensearchInterfaceImpl, post *model.Post, createTime time.Time) { //nolint:unused
t.Helper()
indexName := common.BuildPostIndexName(*th.Server.Config().ElasticsearchSettings.AggregatePostsAfterDays,
common.IndexBasePosts,
common.IndexBasePosts_MONTH,
createTime.Add(-1*24*time.Hour),
model.GetMillisForTime(createTime),
)
searchPost, err := common.ESPostFromPost(post, "teamID", "O", true)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(*esImpl.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second)
defer cancel()
buf, err := json.Marshal(searchPost)
require.NoError(t, err)
_, err = esImpl.client.Index(ctx, opensearchapi.IndexReq{
Index: indexName,
DocumentID: post.Id,
Body: bytes.NewReader(buf),
})
require.NoError(t, err)
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
ejobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs"
)
type OpenSearchAggregatorScheduler struct {
jobServer *jobs.JobServer
server *app.Server
}
func (s *OpenSearchAggregatorScheduler) Enabled(cfg *model.Config) bool {
if license := s.server.License(); license == nil || !*license.Features.Elasticsearch {
return false
}
if *cfg.ElasticsearchSettings.EnableIndexing {
return true
}
return false
}
func (s *OpenSearchAggregatorScheduler) NextScheduleTime(cfg *model.Config, now time.Time, pendingJobs bool, lastSuccessfulJob *model.Job) *time.Time {
parsedTime, err := time.Parse("15:04", *cfg.ElasticsearchSettings.PostsAggregatorJobStartTime)
if err != nil {
s.server.Log().Error("Cannot determine next schedule time for opensearch post aggregator. PostsAggregatorJobStartTime config value is invalid.", mlog.Err(err))
return nil
}
return jobs.GenerateNextStartDateTime(now, parsedTime)
}
func (s *OpenSearchAggregatorScheduler) ScheduleJob(rctx request.CTX, _ *model.Config, pendingJobs bool, _ *model.Job) (*model.Job, *model.AppError) {
if pendingJobs {
s.server.Log().Warn("An aggregator job is already running. Skipping.")
return nil, nil
}
// Don't schedule a job if we already have a running bulk indexing job
count, err := s.jobServer.Store.Job().GetCountByStatusAndType(model.JobStatusInProgress, model.JobTypeElasticsearchPostIndexing)
if err != nil {
return nil, model.NewAppError(
"ScheduleJob",
model.NoTranslation,
nil,
"",
http.StatusInternalServerError).Wrap(err)
}
if count > 0 {
return nil, nil
}
return s.jobServer.CreateJob(rctx, model.JobTypeElasticsearchPostAggregation, nil)
}
func (esi *OpensearchAggregatorInterfaceImpl) MakeScheduler() ejobs.Scheduler {
return &OpenSearchAggregatorScheduler{
server: esi.Server,
jobServer: esi.Server.Jobs,
}
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"bytes"
"context"
"encoding/json"
"sync"
"time"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
)
type Bulk struct {
mut sync.Mutex
buf *bytes.Buffer
client *opensearchapi.Client
bulkSettings common.BulkSettings
reqTimeout time.Duration
logger mlog.LoggerIFace
quitFlusher chan struct{}
quitFlusherWg sync.WaitGroup
pendingRequests int
}
func NewBulk(bulkSettings common.BulkSettings,
client *opensearchapi.Client,
reqTimeout time.Duration,
logger mlog.LoggerIFace,
) *Bulk {
b := &Bulk{
bulkSettings: bulkSettings,
reqTimeout: reqTimeout,
logger: logger,
client: client,
quitFlusher: make(chan struct{}),
buf: &bytes.Buffer{},
}
// Start the timer only if a flush interval was specified
if bulkSettings.FlushInterval > 0 {
b.quitFlusherWg.Add(1)
go b.periodicFlusher()
}
return b
}
// IndexOp is a helper function to add an IndexOperation to the current bulk request.
// doc argument can be a []byte, json.RawMessage or a struct.
func (r *Bulk) IndexOp(op *types.IndexOperation, doc any) error {
r.mut.Lock()
defer r.mut.Unlock()
operation := types.OperationContainer{Index: op}
header, err := json.Marshal(operation)
if err != nil {
return err
}
r.buf.Write(header)
r.buf.Write([]byte("\n"))
switch v := doc.(type) {
case []byte:
r.buf.Write(v)
case json.RawMessage:
r.buf.Write(v)
default:
body, err := json.Marshal(doc)
if err != nil {
return err
}
r.buf.Write(body)
}
r.buf.Write([]byte("\n"))
return r.flushIfNecessary()
}
// DeleteOp is a helper function to add a DeleteOperation to the current bulk request.
func (r *Bulk) DeleteOp(op *types.DeleteOperation) error {
r.mut.Lock()
defer r.mut.Unlock()
operation := types.OperationContainer{Delete: op}
header, err := json.Marshal(operation)
if err != nil {
return err
}
r.buf.Write(header)
r.buf.Write([]byte("\n"))
return r.flushIfNecessary()
}
// flushIfNecessary flushes the pending buffer if needed.
// It MUST be called with an already acquired mutex.
func (r *Bulk) flushIfNecessary() error {
// Check data threshold, only if specified
if r.bulkSettings.FlushBytes > 0 {
if r.buf.Len() >= r.bulkSettings.FlushBytes {
return r._flush()
}
}
r.pendingRequests++
// Check number of requests threshold, only if specified
if r.bulkSettings.FlushNumReqs > 0 {
if r.pendingRequests > r.bulkSettings.FlushNumReqs {
return r._flush()
}
}
return nil
}
func (r *Bulk) Stop() error {
r.mut.Lock()
r.logger.Info("Stopping Bulk processor")
var flushErr error
if r.pendingRequests > 0 {
flushErr = r._flush()
}
if r.bulkSettings.FlushInterval > 0 {
close(r.quitFlusher)
}
r.mut.Unlock()
// The periodic flusher may already be waiting for mut after its timer
// fires, so release the mutex before waiting for it to exit.
if r.bulkSettings.FlushInterval > 0 {
r.quitFlusherWg.Wait()
}
return flushErr
}
func (r *Bulk) periodicFlusher() {
defer r.quitFlusherWg.Done()
for {
select {
case <-time.After(r.bulkSettings.FlushInterval):
r.mut.Lock()
if r.pendingRequests > 0 {
if err := r._flush(); err != nil {
r.logger.Warn("Error flushing live indexing buffer", mlog.Err(err))
}
}
r.mut.Unlock()
case <-r.quitFlusher:
return
}
}
}
// _flush MUST be called with an acquired lock.
func (r *Bulk) _flush() error {
if r.pendingRequests == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), r.reqTimeout)
defer cancel()
_, err := r.client.Bulk(ctx, opensearchapi.BulkReq{
Body: bytes.NewReader(r.buf.Bytes()),
})
if err != nil {
return err
}
r.buf.Reset()
r.pendingRequests = 0
return nil
}
func (r *Bulk) Flush() error {
r.mut.Lock()
defer r.mut.Unlock()
return r._flush()
}

View File

@@ -0,0 +1,587 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/opensearch-project/opensearch-go/v4"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/require"
)
// setupBulkClient creates a test bulk client with common setup
func setupBulkClient(t *testing.T, flushBytes int, flushNumReqs int, flushInterval time.Duration) *Bulk {
th := api4.SetupEnterprise(t)
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://opensearch:9201")
os.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", "opensearch")
}
th.App.UpdateConfig(func(cfg *model.Config) {
if os.Getenv("IS_CI") == "true" {
*cfg.ElasticsearchSettings.ConnectionURL = "http://opensearch:9201"
} else {
*cfg.ElasticsearchSettings.ConnectionURL = "http://localhost:9201"
}
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
})
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
bulk := NewBulk(
common.BulkSettings{
FlushBytes: flushBytes,
FlushInterval: flushInterval,
FlushNumReqs: flushNumReqs,
},
client,
time.Duration(*th.App.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second,
th.Server.Platform().Log())
return bulk
}
// createTestPost creates a test post for indexing
func createTestPost(t *testing.T, message string) *common.ESPost {
post, err := common.ESPostFromPost(&model.Post{
Id: model.NewId(),
Message: message,
}, "myteam", "O", true)
require.NoError(t, err)
return post
}
func TestBulkProcessor(t *testing.T) {
bulk := setupBulkClient(t, 0, 10, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
post := createTestPost(t, "hello world")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("myindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulk.pendingRequests)
err = bulk.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulk.pendingRequests)
}
func TestNewBulk(t *testing.T) {
bulk := setupBulkClient(t, 1024, 10, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
t.Run("creates bulk client without periodic flusher", func(t *testing.T) {
require.NotNil(t, bulk)
require.NotNil(t, bulk.client)
require.NotNil(t, bulk.logger)
require.NotNil(t, bulk.buf)
require.Equal(t, 0, bulk.pendingRequests)
require.Equal(t, 1024, bulk.bulkSettings.FlushBytes)
require.Equal(t, 10, bulk.bulkSettings.FlushNumReqs)
})
t.Run("creates bulk client with periodic flusher", func(t *testing.T) {
bulkWithTimer := setupBulkClient(t, 1024, 10, 100*time.Millisecond)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
require.NotNil(t, bulkWithTimer)
require.Equal(t, 100*time.Millisecond, bulkWithTimer.bulkSettings.FlushInterval)
err := bulkWithTimer.Stop()
require.NoError(t, err)
})
err := bulk.Stop()
require.NoError(t, err)
}
func TestIndexOp(t *testing.T) {
bulk := setupBulkClient(t, 0, 10, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
t.Run("single index operation with struct", func(t *testing.T) {
post := createTestPost(t, "test message")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulk.pendingRequests)
// Verify buffer has content
require.Greater(t, bulk.buf.Len(), 0)
})
t.Run("index operation with []byte", func(t *testing.T) {
initialRequests := bulk.pendingRequests
docId := model.NewId()
data := []byte(`{"message": "test byte slice"}`)
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(docId),
}, data)
require.NoError(t, err)
require.Equal(t, initialRequests+1, bulk.pendingRequests)
})
t.Run("index operation with json.RawMessage", func(t *testing.T) {
initialRequests := bulk.pendingRequests
docId := model.NewId()
jsonData := []byte(`{"message": "test raw message"}`)
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(docId),
}, jsonData)
require.NoError(t, err)
require.Equal(t, initialRequests+1, bulk.pendingRequests)
})
t.Run("multiple index operations", func(t *testing.T) {
initialRequests := bulk.pendingRequests
for i := range 5 {
post := createTestPost(t, fmt.Sprintf("test message %d", i))
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
}
require.Equal(t, initialRequests+5, bulk.pendingRequests)
})
t.Run("auto flush on request threshold", func(t *testing.T) {
// Create a new client with low flush threshold
bulk2 := setupBulkClient(t, 0, 2, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
post1 := createTestPost(t, "first message")
err := bulk2.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post1.Id),
}, post1)
require.NoError(t, err)
require.Equal(t, 1, bulk2.pendingRequests)
post2 := createTestPost(t, "second message")
err = bulk2.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post2.Id),
}, post2)
require.NoError(t, err)
require.Equal(t, 2, bulk2.pendingRequests)
// Third operation should trigger flush
post3 := createTestPost(t, "third message")
err = bulk2.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post3.Id),
}, post3)
require.NoError(t, err)
require.Equal(t, 0, bulk2.pendingRequests)
err = bulk2.Stop()
require.NoError(t, err)
})
err := bulk.Stop()
require.NoError(t, err)
}
func TestDeleteOp(t *testing.T) {
bulk := setupBulkClient(t, 0, 10, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
t.Run("single delete operation", func(t *testing.T) {
docId := model.NewId()
err := bulk.DeleteOp(&types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
require.Equal(t, 1, bulk.pendingRequests)
// Verify buffer has content
require.Greater(t, bulk.buf.Len(), 0)
})
t.Run("multiple delete operations", func(t *testing.T) {
initialRequests := bulk.pendingRequests
for range 3 {
docId := model.NewId()
err := bulk.DeleteOp(&types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
}
require.Equal(t, initialRequests+3, bulk.pendingRequests)
})
t.Run("auto flush on request threshold", func(t *testing.T) {
// Create a new client with low flush threshold
bulk2 := setupBulkClient(t, 0, 2, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
// Add two delete operations
for range 2 {
docId := model.NewId()
err := bulk2.DeleteOp(&types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
}
require.Equal(t, 2, bulk2.pendingRequests)
// Third operation should trigger flush
docId := model.NewId()
err := bulk2.DeleteOp(&types.DeleteOperation{
Index_: new("testindex"),
Id_: new(docId),
})
require.NoError(t, err)
require.Equal(t, 0, bulk2.pendingRequests)
err = bulk2.Stop()
require.NoError(t, err)
})
err := bulk.Stop()
require.NoError(t, err)
}
func TestFlush(t *testing.T) {
bulk := setupBulkClient(t, 0, 10, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
t.Run("flush with pending operations", func(t *testing.T) {
post := createTestPost(t, "test message")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulk.pendingRequests)
err = bulk.Flush()
require.NoError(t, err)
require.Equal(t, 0, bulk.pendingRequests)
// Verify buffer is empty after flush
require.Equal(t, 0, bulk.buf.Len())
})
t.Run("flush with no pending operations", func(t *testing.T) {
require.Equal(t, 0, bulk.pendingRequests)
err := bulk.Flush()
require.NoError(t, err)
require.Equal(t, 0, bulk.pendingRequests)
})
err := bulk.Stop()
require.NoError(t, err)
}
func TestStop(t *testing.T) {
t.Run("stop with pending operations", func(t *testing.T) {
bulk := setupBulkClient(t, 0, 10, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
post := createTestPost(t, "test message")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulk.pendingRequests)
err = bulk.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulk.pendingRequests)
})
t.Run("stop with no pending operations", func(t *testing.T) {
bulk := setupBulkClient(t, 0, 10, 0)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
require.Equal(t, 0, bulk.pendingRequests)
err := bulk.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulk.pendingRequests)
})
t.Run("stop with periodic flusher", func(t *testing.T) {
bulk := setupBulkClient(t, 0, 10, 100*time.Millisecond)
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
post := createTestPost(t, "test message")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
require.Equal(t, 1, bulk.pendingRequests)
// Stop should flush pending operations and stop the periodic flusher
err = bulk.Stop()
require.NoError(t, err)
require.Equal(t, 0, bulk.pendingRequests)
})
}
func TestStopShutsDownPeriodicFlusher(t *testing.T) {
tests := []struct {
name string
status int
wantErr bool
pendingRequests int
}{
{name: "successful pending flush", status: http.StatusOK},
{name: "failed pending flush", status: http.StatusInternalServerError, wantErr: true, pendingRequests: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
const flushInterval = 50 * time.Millisecond
requestStarted := make(chan struct{}, 1)
releaseRequest := make(chan struct{})
var releaseOnce sync.Once
release := func() {
releaseOnce.Do(func() { close(releaseRequest) })
}
t.Cleanup(release)
bulk := setupBulkClientWithHandler(t, flushInterval, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
select {
case requestStarted <- struct{}{}:
default:
}
<-releaseRequest
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.status)
if tt.status == http.StatusOK {
_, _ = w.Write([]byte(`{"errors":false,"items":[]}`))
return
}
_, _ = w.Write([]byte(`{"error":{"type":"test_error","reason":"bulk request failed"},"status":500}`))
}))
post := createTestPost(t, "test message")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
stopDone := make(chan error, 1)
go func() {
stopDone <- bulk.Stop()
}()
select {
case <-requestStarted:
case <-time.After(2 * time.Second):
require.FailNow(t, "timed out waiting for final flush to start")
}
// Stop still holds mut while the final flush is blocked. Waiting for a
// timer interval forces the periodic flusher to contend for that mutex.
time.Sleep(2 * flushInterval)
release()
select {
case err = <-stopDone:
case <-time.After(2 * time.Second):
require.FailNow(t, "Stop deadlocked while waiting for the periodic flusher")
}
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.pendingRequests, bulk.pendingRequests)
assertFlusherStopped(t, bulk)
})
}
}
func setupBulkClientWithHandler(t *testing.T, flushInterval time.Duration, handler http.Handler) *Bulk {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := opensearchapi.NewClient(opensearchapi.Config{
Client: opensearch.Config{
Addresses: []string{server.URL},
DisableRetry: true,
},
})
require.NoError(t, err)
return NewBulk(
common.BulkSettings{
FlushInterval: flushInterval,
FlushNumReqs: 10,
},
client,
time.Second,
mlog.CreateConsoleTestLogger(t),
)
}
func assertFlusherStopped(t *testing.T, bulk *Bulk) {
t.Helper()
select {
case <-bulk.quitFlusher:
default:
require.Fail(t, "periodic flusher was not stopped")
}
}
func TestFlushThresholds(t *testing.T) {
t.Run("flush on bytes threshold", func(t *testing.T) {
// Create a client with very small byte threshold
bulk := setupBulkClient(t, 100, 0, 0) // 100 bytes threshold
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
// Add operations that should exceed the byte threshold
for range 5 {
post := createTestPost(t, "This is a long message that should help us exceed the byte threshold for testing")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
}
// Should have been flushed due to byte threshold
require.Equal(t, 0, bulk.pendingRequests)
err := bulk.Stop()
require.NoError(t, err)
})
t.Run("no flush when thresholds not met", func(t *testing.T) {
bulk := setupBulkClient(t, 100000, 10, 0) // High thresholds
defer func() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}()
// Add a few operations that shouldn't trigger flush
for range 3 {
post := createTestPost(t, "short")
err := bulk.IndexOp(&types.IndexOperation{
Index_: new("testindex"),
Id_: new(post.Id),
}, post)
require.NoError(t, err)
fmt.Println("PENDING REQS:", bulk.pendingRequests)
}
// Should not have been flushed
require.Equal(t, 3, bulk.pendingRequests)
err := bulk.Stop()
require.NoError(t, err)
})
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/opensearch-project/opensearch-go/v4"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func newTestClient(t *testing.T, handler http.Handler) *opensearchapi.Client {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
client, err := opensearchapi.NewClient(opensearchapi.Config{
Client: opensearch.Config{
Addresses: []string{ts.URL},
MaxRetries: 0,
},
})
require.NoError(t, err)
return client
}
func infoHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"name":"node","cluster_name":"test","cluster_uuid":"abc","version":{"distribution":"opensearch","number":%q,"build_type":"tar","build_hash":"abc","build_date":"2024-01-01","build_snapshot":false,"lucene_version":"9.7.0","minimum_wire_compatibility_version":"7.10.0","minimum_index_compatibility_version":"7.0.0"},"tagline":"The OpenSearch Project: https://opensearch.org/"}`, version)
}
}
func TestCheckVersion(t *testing.T) {
tests := []struct {
name string
version string
wantVersion string
wantMajor int
wantErrID string
wantUnsupported bool
}{
{
name: "OpenSearch 2 is supported",
version: "2.11.0",
wantVersion: "2.11.0",
wantMajor: 2,
},
{
name: "OpenSearch 3 is supported",
version: "3.0.0",
wantVersion: "3.0.0",
wantMajor: 3,
},
{
name: "OpenSearch 1 is too old, but allowed",
version: "1.3.0",
wantVersion: "1.3.0",
wantMajor: 1,
wantUnsupported: true,
},
{
name: "OpenSearch 4 is too new, but allowed",
version: "4.0.0",
wantVersion: "4.0.0",
wantMajor: 4,
wantUnsupported: true,
},
{
name: "invalid version string",
version: "invalid",
wantErrID: "ent.elasticsearch.start.parse_server_version.app_error",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
var buf mlog.Buffer
require.NoError(t, mlog.AddWriterTarget(logger, &buf, true, mlog.LvlError))
client := newTestClient(t, infoHandler(tc.version))
version, major, appErr := checkVersion(context.Background(), client, logger)
require.NoError(t, logger.Flush())
if tc.wantErrID != "" {
require.NotNil(t, appErr)
assert.Equal(t, tc.wantErrID, appErr.Id)
return
}
require.Nil(t, appErr)
assert.Equal(t, tc.wantVersion, version)
assert.Equal(t, tc.wantMajor, major)
if tc.wantUnsupported {
assert.Contains(t, buf.String(), "Unsupported OpenSearch version")
assert.Contains(t, buf.String(), fmt.Sprintf(`"version":%q`, tc.wantVersion))
assert.Contains(t, buf.String(), `"min_version":2`)
assert.Contains(t, buf.String(), `"max_version":3`)
} else {
assert.Empty(t, buf.String())
}
})
}
}
func TestCheckVersionConnectionError(t *testing.T) {
ts := httptest.NewServer(http.NotFoundHandler())
ts.Close() // close immediately to force connection error
client, err := opensearchapi.NewClient(opensearchapi.Config{
Client: opensearch.Config{
Addresses: []string{ts.URL},
MaxRetries: 0,
},
})
require.NoError(t, err)
_, _, appErr := checkVersion(context.Background(), client, mlog.CreateConsoleTestLogger(t))
require.NotNil(t, appErr)
assert.Equal(t, "ent.elasticsearch.start.get_server_version.app_error", appErr.Id)
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"crypto/tls"
"net/http"
"time"
"github.com/opensearch-project/opensearch-go/v4"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
func createClient(logger mlog.LoggerIFace, cfg *model.Config, fileBackend filestore.FileBackend, debugLogging bool) (*opensearchapi.Client, *model.AppError) {
esCfg, appErr := createClientConfig(logger, cfg, fileBackend, debugLogging)
if appErr != nil {
return nil, appErr
}
client, err := opensearchapi.NewClient(*esCfg)
if err != nil {
return nil, model.NewAppError("Opensearch.createClient", "ent.elasticsearch.create_client.connect_failed", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
}
return client, nil
}
func createClientConfig(logger mlog.LoggerIFace, cfg *model.Config, fileBackend filestore.FileBackend, debugLogging bool) (*opensearchapi.Config, *model.AppError) {
tp := http.DefaultTransport.(*http.Transport).Clone()
tp.TLSClientConfig = &tls.Config{
InsecureSkipVerify: *cfg.ElasticsearchSettings.SkipTLSVerification,
}
osCfg := &opensearchapi.Config{
Client: opensearch.Config{
Addresses: []string{*cfg.ElasticsearchSettings.ConnectionURL},
RetryBackoff: func(i int) time.Duration { return time.Duration(i) * 100 * time.Millisecond }, // A minimal backoff function
RetryOnStatus: []int{502, 503, 504, 429}, // Retry on 429 TooManyRequests statuses
MaxRetries: 3,
DiscoverNodesOnStart: cfg.ElasticsearchSettings.Sniff,
},
}
if osCfg.Client.DiscoverNodesOnStart != nil && *osCfg.Client.DiscoverNodesOnStart {
osCfg.Client.DiscoverNodesInterval = 30 * time.Second
}
if *cfg.ElasticsearchSettings.ClientCert != "" {
appErr := configureClientCertificate(tp.TLSClientConfig, cfg, fileBackend)
if appErr != nil {
return nil, appErr
}
}
// custom CA
if *cfg.ElasticsearchSettings.CA != "" {
appErr := configureCA(&osCfg.Client, cfg, fileBackend)
if appErr != nil {
return nil, appErr
}
}
osCfg.Client.Transport = tp
if *cfg.ElasticsearchSettings.Username != "" {
osCfg.Client.Username = *cfg.ElasticsearchSettings.Username
osCfg.Client.Password = *cfg.ElasticsearchSettings.Password
}
// This is a compatibility mode from previous config settings.
// We have to conditionally enable debug logging due to
// https://github.com/elastic/elastic-transport-go/issues/22
// Although, this is opensearch, the issue is the same.
if *cfg.ElasticsearchSettings.Trace == "all" && debugLogging {
osCfg.Client.EnableDebugLogger = true
}
osCfg.Client.Logger = common.NewLogger("Opensearch", logger, *cfg.ElasticsearchSettings.Trace == "all")
return osCfg, nil
}
func configureCA(esCfg *opensearch.Config, cfg *model.Config, fb filestore.FileBackend) *model.AppError {
// read the certificate authority (CA) file
clientCA, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.CA)
if err != nil {
return model.NewAppError("Opensearch.createClient", "ent.elasticsearch.create_client.ca_cert_missing", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
}
esCfg.CACert = clientCA
return nil
}
func configureClientCertificate(tlsConfig *tls.Config, cfg *model.Config, fb filestore.FileBackend) *model.AppError {
// read the client certificate file
clientCert, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.ClientCert)
if err != nil {
return model.NewAppError("Opensearch.createClient", "ent.elasticsearch.create_client.client_cert_missing", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
}
// read the client key file
clientKey, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.ClientKey)
if err != nil {
return model.NewAppError("Opensearch.createClient", "ent.elasticsearch.create_client.client_key_missing", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
}
// load the client key and certificate
certificate, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
return model.NewAppError("Opensearch.createClient", "ent.elasticsearch.create_client.client_cert_malformed", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
}
// update the TLS config
tlsConfig.Certificates = []tls.Certificate{certificate}
return nil
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"context"
"io"
"net/http"
"sync/atomic"
"time"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/opensearch-project/opensearch-go/v4/opensearchutil"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type OpensearchIndexerInterfaceImpl struct {
Server *app.Server
bulkProcessor opensearchutil.BulkIndexer
}
func (esi *OpensearchIndexerInterfaceImpl) MakeWorker() model.Worker {
const workerName = "EnterpriseOpensearchIndexer"
// Initializing logger
logger := esi.Server.Jobs.Logger().With(mlog.String("worker_name", workerName))
// Creating the client
client, appErr := createClient(logger, esi.Server.Jobs.Config(), esi.Server.Platform().FileBackend(), true)
if appErr != nil {
logger.Error("Worker: Failed to Create Client", mlog.Err(appErr))
return nil
}
var realFailed atomic.Int64
return common.NewIndexerWorker(workerName, model.ElasticsearchSettingsOSBackend,
esi.Server.Jobs,
logger,
esi.Server.Platform().FileBackend(),
esi.Server.License,
func() error {
// Creating the bulk indexer from the client.
biCfg := opensearchutil.BulkIndexerConfig{
Client: client,
OnError: func(_ context.Context, err error) {
logger.Error("Error from opensearch bulk indexer", mlog.Err(err))
},
Timeout: time.Duration(*esi.Server.Jobs.Config().ElasticsearchSettings.RequestTimeoutSeconds) * time.Second,
NumWorkers: common.NumIndexWorkers(),
}
if *esi.Server.Jobs.Config().ElasticsearchSettings.Trace == "all" {
biCfg.DebugLogger = common.NewBulkIndexerLogger(logger, workerName)
}
var err error
esi.bulkProcessor, err = opensearchutil.NewBulkIndexer(biCfg)
return err
},
// Function to add an item in the bulk processor
func(indexName, indexOp, docID string, body io.ReadSeeker) error {
return esi.bulkProcessor.Add(context.Background(), opensearchutil.BulkIndexerItem{
Index: indexName,
Action: indexOp,
DocumentID: docID,
Body: body,
OnFailure: func(_ context.Context, item opensearchutil.BulkIndexerItem, resp opensearchapi.BulkRespItem, err error) {
if item.Action == "delete" && resp.Status == http.StatusNotFound {
return
}
realFailed.Add(1)
fields := []mlog.Field{
mlog.String("index", item.Index),
mlog.String("doc_id", item.DocumentID),
mlog.String("action", item.Action),
mlog.Int("status", resp.Status),
}
if resp.Error != nil {
fields = append(fields,
mlog.String("error_type", resp.Error.Type),
mlog.String("error_reason", resp.Error.Reason),
)
}
if err != nil {
fields = append(fields, mlog.Err(err))
}
logger.Warn("Bulk indexer: failed to index document", fields...)
},
})
},
// Closing the bulk processor and returning the number of failed items.
func() (int64, error) {
err := esi.bulkProcessor.Close(context.Background())
stats := esi.bulkProcessor.Stats()
fields := []mlog.Field{
mlog.Uint("num_indexed", stats.NumIndexed),
mlog.Int("num_failed", realFailed.Load()),
mlog.Uint("stats_num_failed", stats.NumFailed),
mlog.Uint("num_added", stats.NumAdded),
mlog.Uint("num_flushed", stats.NumFlushed),
}
if err != nil {
logger.Warn("Bulk indexer closed with error", append(fields, mlog.Err(err))...)
} else {
logger.Info("Bulk indexer closed", fields...)
}
return realFailed.Load(), err
})
}

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
)
func TestOpenSearchIndexerJobIsEnabled(t *testing.T) {
t.Run("ElasticSearch feature is enabled then job is enabled", func(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t)
th.Server.SetLicense(model.NewTestLicense("elastic_search"))
osImpl := &OpensearchIndexerInterfaceImpl{
Server: th.Server,
}
worker := osImpl.MakeWorker()
config := &model.Config{
ElasticsearchSettings: model.ElasticsearchSettings{
EnableIndexing: new(true),
},
}
result := worker.IsEnabled(config)
assert.Equal(t, result, true)
})
t.Run("there is NO license then job is disabled", func(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t)
th.Server.SetLicense(nil)
osImpl := &OpensearchIndexerInterfaceImpl{
Server: th.Server,
}
worker := osImpl.MakeWorker()
config := &model.Config{
ElasticsearchSettings: model.ElasticsearchSettings{
EnableIndexing: new(true),
},
}
result := worker.IsEnabled(config)
assert.Equal(t, result, false)
})
}
// TestOpenSearchDeleteNonExistentDocument verifies that bulk deletes returning 404
// (document not found) do not cause the indexing job to fail. A soft-deleted post
// that was never indexed is used so the delete operation is guaranteed to 404.
//
// Requires a running OpenSearch instance (skipped otherwise).
func TestOpenSearchDeleteNonExistentDocument(t *testing.T) {
th := api4.SetupEnterprise(t).InitBasic(t)
connURL := "http://localhost:9201"
if os.Getenv("IS_CI") == "true" {
connURL = "http://opensearch:9201"
}
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = connURL
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.SqlSettings.DisableDatabaseSearch = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
_, err := client.Info(context.Background(), nil)
if err != nil {
t.Skipf("OpenSearch not available at %s: %v", connURL, err)
}
// Soft-delete a post so the job issues a bulk delete for it. Since the index
// is empty (no prior reindex), the document doesn't exist and OS returns 404.
_, appErr := th.App.DeletePost(th.Context, th.BasicPost.Id, th.BasicUser.Id)
require.Nil(t, appErr)
impl := OpensearchIndexerInterfaceImpl{Server: th.App.Srv()}
worker := impl.MakeWorker()
require.NotNil(t, worker)
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, worker, nil)
go worker.Run()
defer worker.Stop()
job, appErr := th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
job = waitForIndexingJob(t, th, job.Id, 60*time.Second)
assert.Equal(t, model.JobStatusSuccess, job.Status, "reindex with 404 deletes should succeed")
}
// TestOpenSearchIndexerBulkWriteFailures verifies that a reindex job is marked as
// failed when OpenSearch rejects bulk writes with per-item errors (e.g. a write
// block on the index), rather than silently reporting success.
//
// Requires a running OpenSearch instance (skipped otherwise).
func TestOpenSearchIndexerBulkWriteFailures(t *testing.T) {
th := api4.SetupEnterprise(t).InitBasic(t)
connURL := "http://localhost:9201"
if os.Getenv("IS_CI") == "true" {
connURL = "http://opensearch:9201"
}
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = connURL
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.SqlSettings.DisableDatabaseSearch = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
client := createTestClient(t, th.Context, th.App.Config(), th.App.FileBackend())
// Skip if OpenSearch is not reachable.
_, err := client.Info(context.Background(), nil)
if err != nil {
t.Skipf("OpenSearch not available at %s: %v", connURL, err)
}
impl := OpensearchIndexerInterfaceImpl{Server: th.App.Srv()}
worker := impl.MakeWorker()
require.NotNil(t, worker)
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, worker, nil)
go worker.Run()
defer worker.Stop()
// Phase 1: clean reindex so the indices exist.
job, appErr := th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
job = waitForIndexingJob(t, th, job.Id, 60*time.Second)
require.Equal(t, model.JobStatusSuccess, job.Status, "initial reindex should succeed")
// Collect the indices that were created (skip internal dot-prefixed ones).
settingsResp, err := client.Indices.Settings.Get(context.Background(), &opensearchapi.SettingsGetReq{})
require.NoError(t, err)
var indexNames []string
for name := range settingsResp.GetIndices() {
if !strings.HasPrefix(name, ".") {
indexNames = append(indexNames, name)
}
}
require.NotEmpty(t, indexNames, "initial reindex should have created at least one index")
// Apply a write block to all Mattermost indices and remove it on cleanup.
setWriteBlock := func(blocked bool) {
body := `{"index.blocks.write":true}`
if !blocked {
body = `{"index.blocks.write":false}`
}
for _, idx := range indexNames {
_, putErr := client.Indices.Settings.Put(context.Background(), opensearchapi.SettingsPutReq{
Indices: []string{idx},
Body: strings.NewReader(body),
})
require.NoError(t, putErr, "failed to set write block=%v on index %s", blocked, idx)
}
}
setWriteBlock(true)
defer setWriteBlock(false)
// Phase 2: reindex against write-blocked indices — job must be marked as error.
job, appErr = th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
job = waitForIndexingJob(t, th, job.Id, 60*time.Second)
assert.Equal(t, model.JobStatusError, job.Status, "reindex against write-blocked indices should fail")
}
// waitForIndexingJob polls the job store until the job reaches a terminal status
// or the timeout expires.
func waitForIndexingJob(t *testing.T, th *api4.TestHelper, jobID string, timeout time.Duration) *model.Job {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
job, err := th.App.Srv().Store().Job().Get(th.Context, jobID)
require.NoError(t, err)
switch job.Status {
case model.JobStatusSuccess, model.JobStatusError, model.JobStatusCanceled:
return job
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("job %s did not reach a terminal status within %s", jobID, timeout)
return nil
}
func TestOpenSearchIndexerPending(t *testing.T) {
th := api4.SetupEnterprise(t).InitBasic(t)
// Set up the state for the tests.
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.SqlSettings.DisableDatabaseSearch = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
impl := OpensearchIndexerInterfaceImpl{
Server: th.App.Srv(),
}
worker := impl.MakeWorker()
th.Server.Jobs.RegisterJobType(model.JobTypeElasticsearchPostIndexing, worker, nil)
go worker.Run()
job, appErr := th.App.Srv().Jobs.CreateJob(th.Context, model.JobTypeElasticsearchPostIndexing, map[string]string{})
require.Nil(t, appErr)
worker.JobChannel() <- *job
worker.Stop()
job, err := th.App.Srv().Store().Job().Get(th.Context, job.Id)
require.NoError(t, err)
assert.Equal(t, job.Status, model.JobStatusPending)
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
mainHelper = testlib.NewMainHelper()
defer mainHelper.Close()
api4.SetMainHelper(mainHelper)
mainHelper.Main(m)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,651 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/opensearch-project/opensearch-go/v4"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks"
)
type OpensearchInterfaceTestSuite struct {
common.CommonTestSuite
th *api4.TestHelper
client *opensearchapi.Client
ctx context.Context
fileBackend filestore.FileBackend
}
func TestOpensearchInterfaceTestSuite(t *testing.T) {
testSuite := &OpensearchInterfaceTestSuite{
CommonTestSuite: common.CommonTestSuite{},
}
suite.Run(t, testSuite)
}
func TestStartTemplateFailureDoesNotCreateBulkProcessors(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/":
_, _ = fmt.Fprint(w, `{"version":{"number":"2.11.0"}}`)
case r.URL.Path == "/_cat/plugins":
// Plugin inventory is optional and must not prevent the real template check.
w.WriteHeader(http.StatusInternalServerError)
case strings.HasPrefix(r.URL.Path, "/_index_template/") && strings.HasSuffix(r.URL.Path, "posts"):
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprint(w, `{"error":{"type":"illegal_argument_exception","reason":"failed to parse template","caused_by":{"type":"illegal_argument_exception","reason":"Custom Analyzer [mm_lowercaser] failed to find tokenizer under name [icu_tokenizer]"}},"status":400}`)
case strings.HasPrefix(r.URL.Path, "/_index_template/"):
_, _ = fmt.Fprint(w, `{"acknowledged":true}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
t.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", server.URL)
t.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", model.ElasticsearchSettingsOSBackend)
th := api4.SetupEnterprise(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = server.URL
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 2
})
th.App.Srv().SetLicense(model.NewTestLicense())
impl := &OpensearchInterfaceImpl{Platform: th.Server.Platform()}
appErr := impl.Start(context.Background())
require.NotNil(t, appErr)
require.Contains(t, appErr.Error(), "icu_tokenizer")
require.Contains(t, appErr.Error(), "Verify that the required analysis-icu plugin is installed on every opensearch node")
require.Equal(t, int32(0), impl.ready.Load())
require.Nil(t, impl.bulkProcessor)
require.Nil(t, impl.syncBulkProcessor)
require.Nil(t, impl.Stop())
}
func TestStartWithoutPluginInventoryUsesTemplateValidation(t *testing.T) {
templateRequested := make(chan string, 4)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/":
_, _ = fmt.Fprint(w, `{"version":{"number":"2.11.0"}}`)
case r.URL.Path == "/_cat/plugins":
_, _ = fmt.Fprint(w, `[]`)
case strings.HasPrefix(r.URL.Path, "/_index_template/"):
templateRequested <- strings.TrimPrefix(r.URL.Path, "/_index_template/")
_, _ = fmt.Fprint(w, `{"acknowledged":true}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
t.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", server.URL)
t.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", model.ElasticsearchSettingsOSBackend)
th := api4.SetupEnterprise(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = server.URL
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
impl := &OpensearchInterfaceImpl{Platform: th.Server.Platform()}
defer func() { require.Nil(t, impl.Stop()) }()
appErr := impl.Start(context.Background())
require.Nil(t, appErr)
require.Equal(t, int32(1), impl.ready.Load())
require.Len(t, templateRequested, 4)
requestedTemplates := make([]string, 0, 4)
for range 4 {
requestedTemplates = append(requestedTemplates, <-templateRequested)
}
require.ElementsMatch(t, []string{common.IndexBasePosts, common.IndexBaseChannels, common.IndexBaseUsers, common.IndexBaseFiles}, requestedTemplates)
}
// missingCJKPluginsWarning mirrors the warning Start logs when no CJK analyzer plugin is detected.
const missingCJKPluginsWarning = "EnableCJKAnalyzers is set but no CJK analyzer plugins found installed. Please review opensearch settings."
// pluginsHandler serves the endpoints Start and SearchPosts need, reporting one node per given list
// of plugin names and recording the posts template and search request bodies.
func pluginsHandler(t *testing.T, recorder *common.ClusterRecorder, nodePlugins ...[]string) http.HandlerFunc {
info := infoHandler("2.11.0")
catPlugins := common.CatPluginsResponse(nodePlugins...)
readBody := func(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
body, err := io.ReadAll(r.Body)
if !assert.NoError(t, err) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, false
}
return body, true
}
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/":
info(w, r)
case r.URL.Path == "/_cat/plugins":
_, _ = fmt.Fprint(w, catPlugins)
case strings.HasPrefix(r.URL.Path, "/_index_template/"):
body, ok := readBody(w, r)
if !ok {
return
}
if strings.HasSuffix(r.URL.Path, common.IndexBasePosts) {
recorder.RecordPostsTemplate(body)
}
_, _ = fmt.Fprint(w, `{"acknowledged":true}`)
case strings.HasSuffix(r.URL.Path, "/_search"):
body, ok := readBody(w, r)
if !ok {
return
}
recorder.RecordSearch(body)
_, _ = fmt.Fprint(w, `{"took":1,"timed_out":false,"hits":{"total":{"value":0,"relation":"eq"},"max_score":0,"hits":[]}}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}
}
func setupCJKCluster(t *testing.T, recorder *common.ClusterRecorder, nodePlugins ...[]string) (*api4.TestHelper, *OpensearchInterfaceImpl) {
server := httptest.NewServer(pluginsHandler(t, recorder, nodePlugins...))
t.Cleanup(server.Close)
t.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", server.URL)
t.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", model.ElasticsearchSettingsOSBackend)
th := api4.SetupEnterprise(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.ConnectionURL = server.URL
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
impl := &OpensearchInterfaceImpl{Platform: th.Server.Platform()}
t.Cleanup(func() { require.Nil(t, impl.Stop()) })
return th, impl
}
// TestCJKAnalyzersWithPrefixedPluginNames covers a cluster that reports analysis plugins under the
// prefixed component name a managed service assigns to plugins it does not bundle. AWS OpenSearch
// Service reports the Korean analyzer installed through associate-package as
// "opensearch-analysis-nori" while its bundled analyzers keep their unprefixed names.
func TestCJKAnalyzersWithPrefixedPluginNames(t *testing.T) {
namings := []struct {
Name string
Plugins []string
}{
{"only nori prefixed", []string{"analysis-icu", "opensearch-analysis-nori", "analysis-kuromoji", "analysis-smartcn"}},
{"every analysis plugin prefixed", []string{"opensearch-analysis-icu", "opensearch-analysis-nori", "opensearch-analysis-kuromoji", "opensearch-analysis-smartcn"}},
}
for _, naming := range namings {
t.Run(naming.Name, func(t *testing.T) {
recorder := &common.ClusterRecorder{}
th, impl := setupCJKCluster(t, recorder, naming.Plugins)
require.Nil(t, impl.Start(context.Background()))
expectedSubFields := map[string]string{
"nori": "mm_nori",
"kuromoji": "mm_kuromoji",
"smartcn": "mm_smartcn",
}
t.Run("the posts index template maps every CJK sub-field", func(t *testing.T) {
template := recorder.PostsTemplate()
require.NotEmpty(t, template, "no posts index template was created")
require.Equal(t, expectedSubFields, common.TemplatePropertyFields(t, template, "message"))
require.Equal(t, expectedSubFields, common.TemplatePropertyFields(t, template, "attachments"))
require.Subset(t, common.TemplateAnalyzers(t, template), []string{"mm_nori", "mm_kuromoji", "mm_smartcn"})
})
t.Run("no missing plugin warning is logged", func(t *testing.T) {
require.NoError(t, th.TestLogger.Flush())
testlib.AssertNoLog(t, th.LogBuffer, mlog.LvlWarn.Name, missingCJKPluginsWarning)
})
channels := model.ChannelList{{Id: model.NewId(), TeamId: model.NewId(), Type: model.ChannelTypeOpen}}
searchFields := func(t *testing.T, terms string) [][]string {
t.Helper()
recorder.ResetSearches()
_, _, appErr := impl.SearchPosts(channels, model.ParseSearchParams(terms, 0), 0, 20)
require.Nil(t, appErr)
bodies := recorder.SearchBodies()
require.Len(t, bodies, 1)
return common.SimpleQueryStringFields(t, bodies[0])
}
t.Run("a CJK query searches the CJK sub-fields", func(t *testing.T) {
fields := searchFields(t, "검색")
require.Contains(t, fields, []string{"message", "message.nori", "message.kuromoji", "message.smartcn"})
require.Contains(t, fields, []string{"attachments", "attachments.nori", "attachments.kuromoji", "attachments.smartcn"})
})
t.Run("a non-CJK query only searches the base fields", func(t *testing.T) {
common.RequireOnlyBaseFields(t, searchFields(t, "search"))
})
t.Run("a CJK query only searches the base fields when CJK analyzers are disabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = false
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = true
})
common.RequireOnlyBaseFields(t, searchFields(t, "검색"))
})
})
}
}
// TestCJKAnalyzersWithoutAnyCJKPlugin covers the diagnostic that made this failure mode hard to
// spot: the warning only fires when no CJK analyzer plugin is detected at all.
func TestCJKAnalyzersWithoutAnyCJKPlugin(t *testing.T) {
recorder := &common.ClusterRecorder{}
th, impl := setupCJKCluster(t, recorder, []string{"analysis-icu"})
require.Nil(t, impl.Start(context.Background()))
template := recorder.PostsTemplate()
require.NotEmpty(t, template, "no posts index template was created")
require.Empty(t, common.TemplatePropertyFields(t, template, "message"))
channels := model.ChannelList{{Id: model.NewId(), TeamId: model.NewId(), Type: model.ChannelTypeOpen}}
_, _, appErr := impl.SearchPosts(channels, model.ParseSearchParams("검색", 0), 0, 20)
require.Nil(t, appErr)
bodies := recorder.SearchBodies()
require.Len(t, bodies, 1)
common.RequireOnlyBaseFields(t, common.SimpleQueryStringFields(t, bodies[0]))
require.NoError(t, th.TestLogger.Flush())
testlib.AssertLog(t, th.LogBuffer, mlog.LvlWarn.Name, missingCJKPluginsWarning)
}
func (s *OpensearchInterfaceTestSuite) SetupSuite() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://opensearch:9201")
os.Setenv("MM_ELASTICSEARCHSETTINGS_BACKEND", "opensearch")
}
s.th = api4.SetupEnterprise(s.T()).InitBasic(s.T())
s.CommonTestSuite.TH = s.th
s.CommonTestSuite.GetDocumentFn = func(index, documentID string) (bool, json.RawMessage, error) {
resp, err := s.client.Document.Get(s.ctx, opensearchapi.DocumentGetReq{
Index: index,
DocumentID: documentID,
})
if resp == nil {
return false, nil, err
}
return resp.Found, resp.Source, err
}
s.CommonTestSuite.RefreshIndexFn = func() error {
_, err := s.client.Indices.Refresh(context.Background(), nil)
return err
}
s.CommonTestSuite.CreateIndexFn = func(index string) error {
_, err := s.client.Indices.Create(s.ctx, opensearchapi.IndicesCreateReq{
Index: index,
})
return err
}
s.CommonTestSuite.GetIndexFn = func(indexPattern string) ([]string, error) {
res, err := s.client.Indices.Get(s.ctx, opensearchapi.IndicesGetReq{
Indices: []string{indexPattern},
})
if err != nil {
return nil, err
}
var names []string
for name := range *res.IndicesGetRespData {
names = append(names, name)
}
return names, nil
}
// Set up the state for the tests.
s.th.App.UpdateConfig(func(cfg *model.Config) {
if os.Getenv("IS_CI") == "true" {
*cfg.ElasticsearchSettings.ConnectionURL = "http://opensearch:9201"
} else {
*cfg.ElasticsearchSettings.ConnectionURL = "http://localhost:9201"
}
*cfg.ElasticsearchSettings.Backend = model.ElasticsearchSettingsOSBackend
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
*cfg.ElasticsearchSettings.EnableAutocomplete = true
*cfg.ElasticsearchSettings.LiveIndexingBatchSize = 1
*cfg.SqlSettings.DisableDatabaseSearch = true
})
s.th.App.Srv().SetLicense(model.NewTestLicense())
if s.fileBackend == nil {
s.fileBackend = &mocks.FileBackend{}
}
// Initialise other stuff for the test.
s.client = createTestClient(s.T(), s.th.Context, s.th.App.Config(), s.th.App.FileBackend())
s.ctx = context.Background()
// Register search engine
s.th.App.SearchEngine().RegisterElasticsearchEngine(&OpensearchInterfaceImpl{Platform: s.th.Server.Platform()})
}
func (s *OpensearchInterfaceTestSuite) TearDownSuite() {
if os.Getenv("IS_CI") == "true" {
os.Setenv("MM_ELASTICSEARCHSETTINGS_CONNECTIONURL", "http://elasticsearch:9201")
os.Unsetenv("MM_ELASTICSEARCHSETTINGS_BACKEND")
}
}
func (s *OpensearchInterfaceTestSuite) SetupTest() {
if strings.Contains(s.T().Name(), "CJK") {
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = true
})
} else {
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableCJKAnalyzers = false
})
}
s.CommonTestSuite.ESImpl = s.th.App.SearchEngine().ElasticsearchEngine
if s.CommonTestSuite.ESImpl.IsActive() {
appErr := s.CommonTestSuite.ESImpl.Stop()
s.Require().Nil(appErr)
}
s.Require().Nil(s.CommonTestSuite.ESImpl.Start(context.Background()))
s.Nil(s.CommonTestSuite.ESImpl.PurgeIndexes(s.th.Context))
s.NoError(s.RefreshIndexFn())
}
func (s *OpensearchInterfaceTestSuite) newBulk(client *opensearchapi.Client) *Bulk {
return NewBulk(
common.BulkSettings{
FlushBytes: 0,
FlushInterval: 0,
FlushNumReqs: 0,
},
client,
time.Second,
s.th.Server.Platform().Log())
}
func (s *OpensearchInterfaceTestSuite) TestStopReleasesBothBulkProcessors() {
impl := s.CommonTestSuite.ESImpl.(*OpensearchInterfaceImpl)
impl.bulkProcessor = s.newBulk(s.client)
impl.syncBulkProcessor = s.newBulk(s.client)
impl.ready.Store(1)
impl.healthy.Store(1)
s.Require().Nil(impl.Stop())
s.Require().Nil(impl.bulkProcessor)
s.Require().Nil(impl.syncBulkProcessor)
s.Require().Nil(impl.client)
s.Require().Equal(int32(0), impl.ready.Load())
s.Require().Equal(int32(0), impl.healthy.Load())
// Stop is idempotent after all resources have been detached.
s.Require().Nil(impl.Stop())
}
type failingRoundTripper struct {
calls atomic.Int32
}
func (t *failingRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) {
t.calls.Add(1)
return nil, errors.New("bulk request failed")
}
func (s *OpensearchInterfaceTestSuite) TestStopAttemptsBothBulkProcessorsAfterError() {
transport := &failingRoundTripper{}
client, err := opensearchapi.NewClient(opensearchapi.Config{
Client: opensearch.Config{
Addresses: []string{"http://localhost:9201"},
DisableRetry: true,
Transport: transport,
},
})
s.Require().NoError(err)
impl := s.CommonTestSuite.ESImpl.(*OpensearchInterfaceImpl)
impl.client = client
impl.bulkProcessor = s.newBulk(client)
impl.bulkProcessor.pendingRequests = 1
impl.syncBulkProcessor = s.newBulk(client)
impl.syncBulkProcessor.pendingRequests = 1
impl.ready.Store(1)
impl.healthy.Store(1)
s.Require().Nil(impl.Stop())
s.Require().Equal(int32(2), transport.calls.Load())
s.Require().Nil(impl.bulkProcessor)
s.Require().Nil(impl.syncBulkProcessor)
}
func (s *OpensearchInterfaceTestSuite) TestStopCleansPartiallyInitializedEngine() {
impl := s.CommonTestSuite.ESImpl.(*OpensearchInterfaceImpl)
impl.client = s.client
impl.bulkProcessor = s.newBulk(s.client)
impl.syncBulkProcessor = s.newBulk(s.client)
impl.ready.Store(0)
impl.healthy.Store(1)
s.Require().Nil(impl.Stop())
s.Require().Nil(impl.bulkProcessor)
s.Require().Nil(impl.syncBulkProcessor)
s.Require().Nil(impl.client)
s.Require().Equal(int32(0), impl.ready.Load())
s.Require().Equal(int32(0), impl.healthy.Load())
}
func (s *OpensearchInterfaceTestSuite) TestSyncBulkIndexChannels() {
s.Run("Should index multiple channels successfully", func() {
// Create test channels
channel1 := &model.Channel{
TeamId: s.th.BasicTeam.Id,
Type: model.ChannelTypeOpen,
Name: "test-channel-1",
DisplayName: "Test Channel 1",
}
channel1.PreSave()
channel2 := &model.Channel{
TeamId: s.th.BasicTeam.Id,
Type: model.ChannelTypePrivate,
Name: "test-channel-2",
DisplayName: "Test Channel 2",
}
channel2.PreSave()
channels := []*model.Channel{channel1, channel2}
// Mock getUserIDsForChannel function
getUserIDsForChannel := func(channel *model.Channel) ([]string, error) {
return []string{s.th.BasicUser.Id, s.th.BasicUser2.Id}, nil
}
teamMemberIDs := []string{s.th.BasicUser.Id, s.th.BasicUser2.Id}
// Test the bulk indexing
appErr := s.CommonTestSuite.ESImpl.SyncBulkIndexChannels(s.th.Context, channels, getUserIDsForChannel, teamMemberIDs)
s.Require().Nil(appErr)
// Refresh the index to ensure data is searchable
s.Require().NoError(s.CommonTestSuite.RefreshIndexFn())
// Verify both channels are indexed
found, _, err := s.CommonTestSuite.GetDocumentFn("channels", channel1.Id)
s.Require().NoError(err)
s.Require().True(found)
found, _, err = s.CommonTestSuite.GetDocumentFn("channels", channel2.Id)
s.Require().NoError(err)
s.Require().True(found)
})
s.Run("Should handle empty channels list", func() {
getUserIDsForChannel := func(channel *model.Channel) ([]string, error) {
return []string{}, nil
}
appErr := s.CommonTestSuite.ESImpl.SyncBulkIndexChannels(s.th.Context, []*model.Channel{}, getUserIDsForChannel, []string{})
s.Require().Nil(appErr)
})
s.Run("Should handle getUserIDsForChannel error", func() {
channel := &model.Channel{
TeamId: s.th.BasicTeam.Id,
Type: model.ChannelTypeOpen,
Name: "test-channel-error",
DisplayName: "Test Channel Error",
}
channel.PreSave()
getUserIDsForChannel := func(channel *model.Channel) ([]string, error) {
return nil, model.NewAppError("TestError", "test.error", nil, "", 500)
}
appErr := s.CommonTestSuite.ESImpl.SyncBulkIndexChannels(s.th.Context, []*model.Channel{channel}, getUserIDsForChannel, []string{})
s.Require().NotNil(appErr)
s.Require().Contains(appErr.Error(), "test.error")
})
}
// TestNoIndexesGracefulHandling verifies that write and search operations
// return nil/empty (not an error) when no indexes exist yet. This covers the
// state before any reindex has run: the index templates are present but the
// actual indexes have never been created.
func (s *OpensearchInterfaceTestSuite) TestNoIndexesGracefulHandling() {
// SetupTest already calls PurgeIndexes, so there are no indexes at this point.
impl := s.CommonTestSuite.ESImpl
rctx := s.th.Context
s.Run("BackfillPostsChannelType", func() {
appErr := impl.BackfillPostsChannelType(rctx, []string{"channel1", "channel2"}, "O")
s.Nil(appErr)
})
s.Run("DeleteChannelPosts", func() {
appErr := impl.DeleteChannelPosts(rctx, s.th.BasicChannel.Id)
s.Nil(appErr)
})
s.Run("DeleteUserPosts", func() {
appErr := impl.DeleteUserPosts(rctx, s.th.BasicUser.Id)
s.Nil(appErr)
})
s.Run("UpdatePostsChannelTypeByChannelId", func() {
appErr := impl.UpdatePostsChannelTypeByChannelId(rctx, s.th.BasicChannel.Id, "O")
s.Nil(appErr)
})
s.Run("SearchFiles", func() {
channels := model.ChannelList{s.th.BasicChannel}
params := model.ParseSearchParams("test", 0)
fileIDs, appErr := impl.SearchFiles(channels, params, 0, 20)
s.Nil(appErr)
s.Empty(fileIDs)
})
s.Run("DeletePostFiles", func() {
appErr := impl.DeletePostFiles(rctx, s.th.BasicPost.Id)
s.Nil(appErr)
})
s.Run("DeleteUserFiles", func() {
appErr := impl.DeleteUserFiles(rctx, s.th.BasicUser.Id)
s.Nil(appErr)
})
s.Run("DeleteFilesBatch", func() {
appErr := impl.DeleteFilesBatch(rctx, model.GetMillis(), 1000)
s.Nil(appErr)
})
}
func (s *OpensearchInterfaceTestSuite) TestTemplateCreationClientError() {
s.Run("Should handle error with CausedBy information from opensearch", func() {
// Invalid template request that will trigger an error with caused_by
invalidTemplateBody := map[string]any{
"index_patterns": []string{"test-invalid-*"},
"template": map[string]any{
"settings": map[string]any{
"analysis": map[string]any{
"analyzer": map[string]any{
"my_analyzer": map[string]any{
"type": "custom",
"tokenizer": "nonexistent_tokenizer",
},
},
},
},
},
}
templateBytes, err := json.Marshal(invalidTemplateBody)
s.Require().NoError(err)
_, err = s.client.IndexTemplate.Create(s.ctx, opensearchapi.IndexTemplateCreateReq{
IndexTemplate: "test-invalid-template",
Body: bytes.NewReader(templateBytes),
})
var osErr *opensearch.StructError
s.Require().ErrorAs(err, &osErr)
s.Require().NotNil(osErr.Err.CausedBy, "Expected CausedBy to be present")
s.Require().NotEmpty(osErr.Err.CausedBy.Type)
s.Require().NotEmpty(osErr.Err.CausedBy.Reason)
// clean up after test
_, _ = s.client.IndexTemplate.Delete(s.ctx, opensearchapi.IndexTemplateDeleteReq{
IndexTemplate: "test-invalid-template",
})
})
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"testing"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks"
)
func createTestClient(t *testing.T, rctx request.CTX, cfg *model.Config, fileStore filestore.FileBackend) *opensearchapi.Client {
t.Helper()
if fileStore == nil {
fileStore = &mocks.FileBackend{}
}
client, err := createClient(rctx.Logger(), cfg, fileStore, true)
require.Nil(t, err)
return client
}