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)
})
}
}