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

39
server/enterprise/LICENSE Normal file
View File

@@ -0,0 +1,39 @@
The Mattermost Source Available License license (the “Source Available License”)
Copyright (c) 2015-present Mattermost
With regard to the Mattermost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Mattermost Terms of Service, available at
https://mattermost.com/enterprise-edition-terms/ (the “EE Terms”), or other
agreement governing the use of the Software, as agreed by you and Mattermost,
and otherwise have a valid Mattermost Enterprise E20 subscription for the
correct number of user seats. Subject to the foregoing sentence, you are free
to modify this Software and publish patches to the Software. You agree that
Mattermost and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such
modifications and/or patches may only be used, copied, modified, displayed,
distributed, or otherwise exploited with a valid Mattermost Enterprise E20
Edition subscription for the correct number of user seats. Notwithstanding
the foregoing, you may copy and modify the Software for development and testing
purposes, without requiring a subscription. You agree that Mattermost and/or
its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is
expressly stated herein. Subject to the foregoing, it is forbidden to copy,
merge, publish, distribute, sublicense, and/or sell the Software.
The full text of this EE License shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Mattermost Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,17 @@
# Enterprise
This folder contains source available enterprise code as well as import directives for closed source enterprise code.
## Build Information
The source code in this folder is only included with builds specifying the `enterprise` or `sourceavailble` build tags. If you have a copy of https://github.com/mattermost/enterprise checked out as a peer to this repository, `enterprise` will be set automatically and the imports from both [`external_imports.go`](external_imports.go) and [`local_imports.go`](local_imports.go) will apply.
In a development environment (when `BUILD_NUMBER` is left undefined or explicitly set to `dev`), the `sourceavailable` build tag will be set automatically and only the imports from [`local_imports.go`](local_imports.go) will apply.
## License
See the [LICENSE file](LICENSE) for license rights and limitations. See also [Mattermost Source Available License](https://docs.mattermost.com/overview/faq.html#mattermost-source-available-license) to learn more.
## Contributing
Contributions to source available enterprise code are welcome. Please see [CONTRIBUTING.md](../../CONTRIBUTING.md).

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
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build enterprise
package enterprise
import (
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/account_migration"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/cluster"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/compliance"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/data_retention"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/ldap"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/cloud"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/notification"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/oauth/google"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/oauth/office365"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/saml"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/oauth/openid"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/license"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/ip_filtering"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/outgoing_oauth_connections"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/access_control"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/message_export"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/message_export/actiance_export"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/push_proxy"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/message_export/csv_export"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/message_export/global_relay_export"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/autotranslation"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/intune"
)

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build enterprise || sourceavailable
package enterprise
import (
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/mattermost/server/v8/enterprise/metrics"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch"
)

View File

@@ -0,0 +1,544 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"cmp"
"slices"
"github.com/mattermost/mattermost/server/public/model"
)
type UserType string
const (
User UserType = "user"
Bot UserType = "bot"
)
type PostExport struct {
model.MessageExport // the MessageExport that this PostExport is providing more information for
UserType UserType // the type of the person that sent the post: "user" or "bot"
// Allows us to differentiate between:
// - "EditedOriginalMsg": the newly created message (new Id), which holds the pre-edited message contents. The
// "EditedNewMsgId" field will point to the message (original Id) which has the post-edited message content.
// - "EditedNewMsg": the post-edited message content. This is confusing, so be careful: in the db, this EditedNewMsg
// is actually the original messageId because we wanted an edited message to have the same messageId as the
// pre-edited message. But for the purposes of exporting and to keep the mental model clear for end-users, we are
// calling this the EditedNewMsg and EditedNewMsgId, because this will hold the NEW post-edited message contents,
// and that's what's important to the end-user viewing the export.
// - "UpdatedNoMsgChange": the message content hasn't changed, but the post was updated for some reason (reaction,
// replied-to, a reply was edited, a reply was deleted (as of 10.2), perhaps other reasons)
// - "Deleted": the message was deleted.
// - "FileDeleted": this message is recording that a file was deleted.
UpdatedType PostUpdatedType
UpdateAt int64 // if this is an updated post, this is the updated time (same as deleted time for deleted posts).
// when a message is edited, the EditedOriginalMsg points to the message Id that now has the newly edited message.
EditedNewMsgId string
Message string // the text body of the post
PreviewsPost string // the post id of the post that is previewed by the permalink preview feature
AttachmentCreates []*FileUploadStartExport // the post's attachments that were uploaded this export period
AttachmentDeletes []PostExport // the post's attachments that were deleted
FileInfo *model.FileInfo // if this was a file PostExport, FileInfo will contain that info. Otherwise, nil.
}
type FileUploadStartExport struct {
model.MessageExport // the post that this upload was attached to
UserEmail string // the email of the person that sent the file
UploadStartTime int64 // utc timestamp (seconds), time at which the user started the upload. Example: 1366611728
FileInfo *model.FileInfo
}
type FileUploadStopExport struct {
model.MessageExport // the post that this upload was attached to
UserEmail string // the email of the person that sent the file
UploadStopTime int64 // utc timestamp (seconds), time at which the user finished the upload. Example: 1366611728
Status string // set to either "Completed" or "Failed" depending on the outcome of the upload operation
FileInfo *model.FileInfo
}
type ChannelExport struct {
ChannelId string
ChannelType model.ChannelType
ChannelName string
DisplayName string
StartTime int64 // utc timestamp (milliseconds), start of export period or create time of channel, whichever is greater.
EndTime int64 // utc timestamp (milliseconds), end of export period or delete time of channel, whichever is lesser.
Posts []PostExport
Files []*model.FileInfo
DeletedFiles []PostExport
UploadStarts []*FileUploadStartExport
UploadStops []*FileUploadStopExport
JoinEvents []JoinExport // start with a list of all users who were present in the channel during the export period
LeaveEvents []LeaveExport // finish with a list of all users who were present in the channel during the export period
// Used by csv, ignored by others
TeamId string
TeamName string
TeamDisplayName string
}
type JoinExport struct {
UserId string
Username string
UserEmail string // the email of the person that joined the channel
UserType UserType // the type of the user that joined the channel
JoinTime int64 // utc timestamp (seconds), time at which the user joined. Example: 1366611728
// when the user left (or batch endTime if they didn't leave). Only used by GlobalRelay
LeaveTime int64
}
type LeaveExport struct {
UserId string
Username string
UserEmail string // the email of the person that left the channel
UserType UserType // the type of the user that left the channel
LeaveTime int64 // utc timestamp (seconds), time at which the user left. Example: 1366611728
// ClosedOut indicates this is a "leave" event created by closing out the channel at the end of an export period.
// Actiance requires all users to be closed out at the end of an export period (each join has a matching leave).
ClosedOut bool
}
type GenericExportData struct {
Exports []ChannelExport
Metadata Metadata
Results RunExportResults
}
// GetGenericExportData assembles all the data in an exportType-agnostic way. Each exportType will process this data into
// the specific format they need to export.
func GetGenericExportData(p ExportParams) (GenericExportData, error) {
// postAuthorsByChannel is a map so that we don't store duplicate authors
postAuthorsByChannel := make(map[string]map[string]ChannelMember)
metadata := Metadata{
Channels: p.ChannelMetadata,
MessagesCount: 0,
AttachmentsCount: 0,
StartTime: p.BatchStartTime,
EndTime: p.BatchEndTime,
}
var results RunExportResults
channelsInThisBatch := make(map[string]bool)
postsByChannel := make(map[string][]PostExport)
filesByChannel := make(map[string][]*model.FileInfo)
uploadStartsByChannel := make(map[string][]*FileUploadStartExport)
uploadStopsByChannel := make(map[string][]*FileUploadStopExport)
deletedFilesByChannel := make(map[string][]PostExport)
processPostAttachments := func(post *model.MessageExport, postExport PostExport, originalPostThatWillBeDeletedLater bool) error {
// originalPostThatWillBeDeletedLater means we are recording this message's original file starts and stops,
// before it was deleted (we'll record that next call to this function)
//
// NOTE: there is an edge case here: the original post is not deleted but the attachment has been deleted.
// See the note in the postToAttachmentsEntries func.
channelId := *post.ChannelId
uploadedFiles, startUploads, stopUploads, deleteFileMessages, err :=
postToAttachmentsEntries(post, p.Db, originalPostThatWillBeDeletedLater)
if err != nil {
return err
}
uploadStartsByChannel[channelId] = append(uploadStartsByChannel[channelId], startUploads...)
uploadStopsByChannel[channelId] = append(uploadStopsByChannel[channelId], stopUploads...)
deletedFilesByChannel[channelId] = append(deletedFilesByChannel[channelId], deleteFileMessages...)
filesByChannel[channelId] = append(filesByChannel[channelId], uploadedFiles...)
postExport.AttachmentCreates = startUploads
postExport.AttachmentDeletes = deleteFileMessages
postsByChannel[channelId] = append(postsByChannel[channelId], postExport)
results.UploadedFiles += len(startUploads)
results.DeletedFiles += len(deleteFileMessages)
// only count uploaded files (not deleted files)
if err := metadata.UpdateCounts(channelId, 1, len(startUploads)); err != nil {
return err
}
return nil
}
for _, post := range p.Posts {
channelId := *post.ChannelId
channelsInThisBatch[channelId] = true
// Was the post deleted (not an edited post), and originally posted during the current job window?
// If so, we need to record it. It may actually belong in an earlier batch, but there's no way to know that
// before now because of the way we export posts (by updateAt).
if IsDeletedMsg(post) && !isEditedOriginalMsg(post) && *post.PostCreateAt >= p.JobStartTime {
results.CreatedPosts++
postExport := createdPostToExportEntry(post)
if err := processPostAttachments(post, postExport, true); err != nil {
return GenericExportData{}, err
}
}
var postExport PostExport
postExport, results = getPostExport(post, results)
if err := processPostAttachments(post, postExport, false); err != nil {
return GenericExportData{}, err
}
if _, ok := postAuthorsByChannel[channelId]; !ok {
postAuthorsByChannel[channelId] = make(map[string]ChannelMember)
}
postAuthorsByChannel[channelId][*post.UserId] = ChannelMember{
UserId: *post.UserId,
Email: *post.UserEmail,
Username: *post.Username,
IsBot: post.IsBot,
}
}
// If the channel is not in channelsInThisBatch (i.e. if it didn't have a post), we need to check if it had
// user activity between this batch's startTime-endTime. If so, add it to the channelsInThisBatch.
for id := range p.ChannelMetadata {
if !channelsInThisBatch[id] {
if ChannelHasActivity(p.ChannelMemberHistories[id], p.BatchStartTime, p.BatchEndTime) {
channelsInThisBatch[id] = true
}
}
}
// Build the channel exports for the channels that had post or user join/leave activity this batch.
channelExports := make([]ChannelExport, 0, len(channelsInThisBatch))
for id := range channelsInThisBatch {
c := metadata.Channels[id]
joinEvents, leaveEvents := getJoinsAndLeaves(p.BatchStartTime, p.BatchEndTime,
p.ChannelMemberHistories[id], postAuthorsByChannel[id])
// We don't have teamName and teamDisplayName from the channelMetaData, but we have it from MessageExport.
// However, if we don't have posts for this channel (only joins and leaves), then we don't have it at all.
var teamName, teamDisplayName string
if posts, ok := postsByChannel[id]; ok {
if len(posts) > 0 {
teamName = model.SafeDereference(posts[0].TeamName)
teamDisplayName = model.SafeDereference(posts[0].TeamDisplayName)
}
}
channelExports = append(channelExports, ChannelExport{
ChannelId: c.ChannelId,
ChannelType: c.ChannelType,
ChannelName: c.ChannelName,
DisplayName: c.ChannelDisplayName,
StartTime: p.BatchStartTime,
EndTime: p.BatchEndTime,
Posts: postsByChannel[id],
Files: filesByChannel[id],
DeletedFiles: deletedFilesByChannel[id],
UploadStarts: uploadStartsByChannel[id],
UploadStops: uploadStopsByChannel[id],
JoinEvents: joinEvents,
LeaveEvents: leaveEvents,
TeamId: model.SafeDereference(c.TeamId),
TeamName: teamName,
TeamDisplayName: teamDisplayName,
})
results.Joins += len(joinEvents)
results.Leaves += len(leaveEvents)
}
return GenericExportData{channelExports, metadata, results}, nil
}
// postToAttachmentsEntries returns every fileInfo as uploadedFiles. It also adds each file into the lists:
//
// startUploads, stopUploads, and deleteFileMessages (for ActianceExport).
// If onlyDeleted = true, only export the deleted entry (if it exists).
func postToAttachmentsEntries(post *model.MessageExport, db MessageExportStore, ignoreDeleted bool) (
uploadedFiles []*model.FileInfo, startUploads []*FileUploadStartExport, stopUploads []*FileUploadStopExport, deleteFileMessages []PostExport, err error) {
// if the post included any files, we need to add special elements to the export.
//
// NOTE: there is an edge case here: the original post is not deleted but the attachment has been deleted.
// 1. If the attachment is deleted sometime later in the future but the post has not been updated (updateAt == createAt)
// then we won't ever get here, there's nothing we can do.
// 2. If the attachment is deleted within this export period, or the post is updated this export period and we
// now see that the attachment is deleted, then we have to output 2 files here:
// a) the original file attachment
// b) the deleted file attachment
// These have to be added the start/stop and deletedFileMessages
if len(post.PostFileIds) == 0 {
return
}
uploadedFiles, err = db.FileInfo().GetForPost(*post.PostId, true, true, false)
if err != nil {
return
}
for _, fileInfo := range uploadedFiles {
if fileInfo.DeleteAt > 0 && !ignoreDeleted {
deleteFileMessages = append(deleteFileMessages, deleteFileToExportEntry(post, fileInfo))
// this was a deleted file, so do not record its start and stop. If the original message was sent in this
// batch, the file transfer will have been exported earlier up when the original message was exported.
//
// However, because of the edge case above, we still need to record start and stop if the post is not deleted.
if IsDeletedMsg(post) {
continue
} // not deleted, so need to add start and stop below.
}
// insert a record of the file upload into the export file
// path to exported file is relative to the fileAttachmentFilestore root,
// which could be different from the exportFilestore root
startUploads = append(startUploads, &FileUploadStartExport{
MessageExport: *post,
UserEmail: *post.UserEmail,
UploadStartTime: *post.PostCreateAt,
FileInfo: fileInfo,
})
stopUploads = append(stopUploads, &FileUploadStopExport{
MessageExport: *post,
UserEmail: *post.UserEmail,
UploadStopTime: *post.PostCreateAt,
Status: "Completed",
FileInfo: fileInfo,
})
}
return
}
func getPostExport(post *model.MessageExport, results RunExportResults) (PostExport, RunExportResults) {
// We have three "kinds" of posts:
// (using "1" and "2" for simplicity)
// - created: Id = new, CreateAt = 1, UpdateAt = 1, DeleteAt = 0
// - deleted: Id = orig, CreateAt = orig, UpdateAt = 2, DeleteAt = 2, props: deleteBy
// - edited: old post gets "created": Id = new, CreateAt = 1, UpdateAt = 2, DeleteAt = 2, originalId: orig
// existing post modified: Id = orig, CreateAt = 1, UpdateAt = 2, DeleteAt = 0
//
// We also have other ways for a post to be updated:
// - a root post in a thread is replied to, when a reply is edited, or (as of 10.2) when a reply is deleted
if isEditedOriginalMsg(post) {
// Post has been edited. This is the original message.
results.EditedOrigMsgPosts++
return editedOriginalMsgToExportEntry(post), results
} else if IsDeletedMsg(post) {
// Post is deleted
results.DeletedPosts++
return deletedPostToExportEntry(post, "delete "+*post.PostMessage), results
} else if *post.PostUpdateAt > *post.PostCreateAt {
// Post has been updated. But what kind?
if model.SafeDereference(post.PostEditAt) > 0 {
// This is an edited post.
results.EditedNewMsgPosts++
return editedNewMsgToExportEntry(post), results
}
// This is just an updated post (e.g. reaction)
results.UpdatedPosts++
return updatedPostToExportEntry(post), results
}
// Post is newly created:
// *post.PostCreateAt == *post.PostUpdateAt && (post.PostDeleteAt == nil || *post.PostDeleteAt == 0)
// but also fallback to this in case there is missing data, which is better than not exporting anything.
results.CreatedPosts++
return createdPostToExportEntry(post), results
}
func getJoinsAndLeaves(startTime int64, endTime int64, channelMembersHistory []*model.ChannelMemberHistoryResult,
postAuthors map[string]ChannelMember) ([]JoinExport, []LeaveExport) {
var leaveEvents []LeaveExport
joins, leaves := GetJoinsAndLeavesForChannel(startTime, endTime, channelMembersHistory, postAuthors)
joinsById := make(map[string]JoinExport, len(joins))
type StillMemberInfo struct {
time int64
userType UserType
userId string
username string
}
stillMember := map[string]StillMemberInfo{}
for _, join := range joins {
userType := User
if join.IsBot {
userType = Bot
}
joinsById[join.UserId] = JoinExport{
UserId: join.UserId,
Username: join.Username,
UserEmail: join.Email,
JoinTime: join.Datetime,
UserType: userType,
LeaveTime: endTime,
}
if value, ok := stillMember[join.Email]; !ok {
stillMember[join.Email] = StillMemberInfo{time: join.Datetime, userType: userType, userId: join.UserId, username: join.Username}
} else if join.Datetime > value.time {
stillMember[join.Email] = StillMemberInfo{time: join.Datetime, userType: userType, userId: join.UserId, username: join.Username}
}
}
for _, leave := range leaves {
userType := User
if leave.IsBot {
userType = Bot
}
leaveEvents = append(leaveEvents, LeaveExport{
UserId: leave.UserId,
Username: leave.Username,
UserEmail: leave.Email,
LeaveTime: leave.Datetime,
UserType: userType,
})
if leave.Datetime > stillMember[leave.Email].time {
delete(stillMember, leave.Email)
}
// record their leave in their initial join
if join, ok := joinsById[leave.UserId]; ok {
join.LeaveTime = leave.Datetime
joinsById[leave.UserId] = join
}
}
// Closing-out the channel for Actiance (each join must have a matching leave).
for email := range stillMember {
leaveEvents = append(leaveEvents, LeaveExport{
UserId: stillMember[email].userId,
Username: stillMember[email].username,
LeaveTime: endTime,
UserEmail: email,
UserType: stillMember[email].userType,
ClosedOut: true,
})
}
joinEvents := make([]JoinExport, 0, len(joinsById))
for _, v := range joinsById {
joinEvents = append(joinEvents, v)
}
slices.SortFunc(joinEvents, func(a, b JoinExport) int {
if a.JoinTime == b.JoinTime {
return cmp.Compare(a.UserEmail, b.UserEmail)
}
return cmp.Compare(a.JoinTime, b.JoinTime)
})
slices.SortFunc(leaveEvents, func(a, b LeaveExport) int {
if a.LeaveTime == b.LeaveTime {
return cmp.Compare(a.UserEmail, b.UserEmail)
}
return cmp.Compare(a.LeaveTime, b.LeaveTime)
})
return joinEvents, leaveEvents
}
func isEditedOriginalMsg(post *model.MessageExport) bool {
return model.SafeDereference(post.PostDeleteAt) > 0 && model.SafeDereference(post.PostOriginalId) != ""
}
func createdPostToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func deletedPostToExportEntry(post *model.MessageExport, newMsg string) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostDeleteAt,
UpdatedType: Deleted,
Message: newMsg,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func editedOriginalMsgToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostUpdateAt,
UpdatedType: EditedOriginalMsg,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
EditedNewMsgId: *post.PostOriginalId,
}
}
func editedNewMsgToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostUpdateAt,
UpdatedType: EditedNewMsg,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func updatedPostToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostUpdateAt,
UpdatedType: UpdatedNoMsgChange,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func deleteFileToExportEntry(post *model.MessageExport, fileInfo *model.FileInfo) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: fileInfo.DeleteAt,
UpdatedType: FileDeleted,
Message: "delete " + fileInfo.Path,
UserType: userType,
PreviewsPost: post.PreviewID(),
FileInfo: fileInfo,
}
}
func UploadStartToExportEntry(u *FileUploadStartExport) PostExport {
userType := User
if u.IsBot {
userType = Bot
}
return PostExport{
MessageExport: u.MessageExport,
UpdateAt: u.FileInfo.UpdateAt,
UserType: userType,
FileInfo: u.FileInfo,
}
}

View File

@@ -0,0 +1,522 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"encoding/json"
"fmt"
"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/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func postToMessageExport(t *testing.T, p *model.Post, u *model.User, c *model.Channel, team *model.Team) model.MessageExport {
t.Helper()
props, err := json.Marshal(p.GetProps())
assert.NoError(t, err)
return model.MessageExport{
TeamId: &team.Id,
TeamName: &team.Name,
TeamDisplayName: &team.DisplayName,
ChannelId: &c.Id,
ChannelName: &c.Name,
ChannelDisplayName: &c.DisplayName,
ChannelType: &c.Type,
UserId: &u.Id,
UserEmail: new(u.Email),
Username: &u.Username,
IsBot: false,
PostId: new(p.Id),
PostCreateAt: new(p.CreateAt),
PostUpdateAt: &p.UpdateAt,
PostDeleteAt: &p.DeleteAt,
PostEditAt: &p.EditAt,
PostMessage: &p.Message,
PostType: &p.Type,
PostRootId: &p.RootId,
PostProps: new(string(props)),
PostOriginalId: &p.OriginalId,
PostFileIds: p.FileIds,
}
}
func Test_getPostExport(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
jobs.DefaultWatcherPollingInterval = 100
th := api4.SetupEnterprise(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense("message_export"))
// the post exports from the db will be random (because they all have the same updateAt), so do it a few times
for i := range 10 {
time.Sleep(time.Millisecond)
start := model.GetMillis()
count, err := th.App.Srv().Store().Post().AnalyticsPostCount(&model.PostCountOptions{ExcludeSystemPosts: true, SincePostID: "", SinceUpdateAt: start})
require.NoError(t, err)
require.Equal(t, 0, int(count))
var posts []*model.Post
// 0 - post edited with 3 simultaneous posts in-between - forward
// original post with edited message
originalPost, err := th.App.Srv().Store().Post().Save(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "message 0",
})
require.NoError(t, err)
require.NotEqual(t, 0, originalPost.UpdateAt, "originalPost's updateAt was zero, test 1")
posts = append(posts, originalPost)
// If we don't sleep, the two messages might not have different CreateAt and UpdateAts
time.Sleep(time.Millisecond)
// 1 - edited post
post, err := th.App.Srv().Store().Post().Update(th.Context, &model.Post{
Id: originalPost.Id,
CreateAt: originalPost.CreateAt,
EditAt: model.GetMillis(),
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "edited message 0",
}, originalPost)
require.NoError(t, err)
require.NotEqual(t, 0, originalPost.UpdateAt, "originalPost's updateAt was zero, test 2")
require.NotEqual(t, 0, post.UpdateAt, "edited post's updateAt was zero, test 2")
posts = append(posts, post)
simultaneous := post.UpdateAt
// Add 8 other posts at the same updateAt
for j := 1; j <= 8; j++ {
// 2 - post 1 at same updateAt
post, err = th.App.Srv().Store().Post().Save(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: fmt.Sprintf("message %d", j),
CreateAt: simultaneous,
})
require.NoError(t, err)
require.NotEqual(t, 0, post.UpdateAt)
posts = append(posts, post)
}
// Use the config fallback for simplicity
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.EnableExport = true
*cfg.MessageExportSettings.ExportFromTimestamp = start
*cfg.MessageExportSettings.BatchSize = 10
})
// the messages can be in any order because all have equal `updateAt`s
expectedExports := []PostExport{
{
MessageExport: postToMessageExport(t, posts[0], th.BasicUser, th.BasicChannel, th.BasicTeam),
UserType: "user",
Message: posts[0].Message,
UpdateAt: posts[1].UpdateAt, // the edit update at
UpdatedType: EditedOriginalMsg,
EditedNewMsgId: posts[1].Id,
},
{
MessageExport: postToMessageExport(t, posts[1], th.BasicUser, th.BasicChannel, th.BasicTeam),
UserType: "user",
Message: posts[1].Message,
UpdateAt: posts[1].UpdateAt,
UpdatedType: EditedNewMsg,
},
}
for j := 2; j < 10; j++ {
expectedExports = append(expectedExports, PostExport{
MessageExport: postToMessageExport(t, posts[j], th.BasicUser, th.BasicChannel, th.BasicTeam),
UserType: "user",
Message: posts[j].Message,
})
}
actualMessageExports, _, err := th.App.Srv().Store().Compliance().MessageExport(th.Context, model.MessageExportCursor{
LastPostUpdateAt: start,
UntilUpdateAt: model.GetMillis(),
}, 10)
require.NoError(t, err)
require.Len(t, actualMessageExports, 10)
for _, export := range actualMessageExports {
require.NotEqual(t, 0, *export.PostUpdateAt)
}
results := RunExportResults{}
var actualExports []PostExport
for _, m := range actualMessageExports {
var postExport PostExport
postExport, results = getPostExport(m, results)
actualExports = append(actualExports, postExport)
}
require.ElementsMatch(t, expectedExports, actualExports, fmt.Sprintf("batch %d", i))
}
}
func TestPostToAttachmentsEntries(t *testing.T) {
chanTypeDirect := model.ChannelTypeDirect
tt := []struct {
name string
post model.MessageExport
attachments []*model.FileInfo
expectedStarts []*FileUploadStartExport
expectedStops []*FileUploadStopExport
expectedFileInfos []*model.FileInfo
expectedDeleteFileMessages []PostExport
ignoreDeleted bool
expectError bool
}{
{
name: "no-attachments",
post: model.MessageExport{
ChannelId: new("Test"),
ChannelDisplayName: new("Test"),
PostCreateAt: new(int64(1)),
PostMessage: new("Some message"),
UserEmail: new("test@test.com"),
UserId: new("test"),
Username: new("test"),
ChannelType: &chanTypeDirect,
},
attachments: nil,
expectedStarts: nil,
expectedStops: nil,
expectedFileInfos: nil,
expectedDeleteFileMessages: nil,
expectError: false,
},
{
name: "one-attachment",
post: model.MessageExport{
PostId: new("test"),
ChannelId: new("Test"),
ChannelDisplayName: new("Test"),
PostCreateAt: new(int64(1)),
PostMessage: new("Some message"),
UserEmail: new("test@test.com"),
UserId: new("test"),
Username: new("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
},
expectedDeleteFileMessages: nil,
expectError: false,
},
{
name: "two-attachment",
post: model.MessageExport{
PostId: new("test"),
ChannelId: new("Test"),
ChannelDisplayName: new("Test"),
PostCreateAt: new(int64(1)),
PostMessage: new("Some message"),
UserEmail: new("test@test.com"),
UserId: new("test"),
Username: new("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345", "54321"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
{Name: "test2", Id: "54321", Path: "filename2.txt"},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}},
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "54321", Name: "test2", Path: "filename2.txt"}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}, Status: "Completed"},
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "54321", Name: "test2", Path: "filename2.txt"}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
{Name: "test2", Id: "54321", Path: "filename2.txt"},
},
expectedDeleteFileMessages: nil,
expectError: false,
},
{
name: "one-attachment-deleted",
post: model.MessageExport{
PostId: new("test"),
ChannelId: new("Test"),
ChannelDisplayName: new("Test"),
PostCreateAt: new(int64(1)),
PostDeleteAt: new(int64(2)),
PostMessage: new("Some message"),
UserEmail: new("test@test.com"),
UserId: new("test"),
Username: new("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345", "54321"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt", DeleteAt: 2}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt", DeleteAt: 2}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedDeleteFileMessages: []PostExport{
{
UserType: "user",
UpdatedType: FileDeleted,
UpdateAt: 2,
Message: "delete " + "filename.txt",
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt", DeleteAt: 2},
},
},
expectError: false,
},
{
name: "one-attachment-deleted, ignore it (only record the creation)",
post: model.MessageExport{
PostId: new("test"),
ChannelId: new("Test"),
ChannelDisplayName: new("Test"),
PostCreateAt: new(int64(1)),
PostDeleteAt: new(int64(2)),
PostMessage: new("Some message"),
UserEmail: new("test@test.com"),
UserId: new("test"),
Username: new("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345", "54321"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedDeleteFileMessages: nil,
ignoreDeleted: true,
expectError: false,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
mockStore := &storetest.Store{}
defer mockStore.AssertExpectations(t)
if len(tc.attachments) > 0 {
call := mockStore.FileInfoStore.On("GetForPost", *tc.post.PostId, true, true, false)
call.Run(func(args mock.Arguments) {
call.Return(tc.attachments, nil)
})
}
files, uploadStarts, uploadStops, deleteFileMessages, err := postToAttachmentsEntries(&tc.post, NewMessageExportStore(mockStore), tc.ignoreDeleted)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
// Need to add the post to the expected starts (to save from copy pasting in the test cases above)
for _, s := range tc.expectedStarts {
s.MessageExport = tc.post
}
// Need to add the post to the expected stops
for _, s := range tc.expectedStops {
s.MessageExport = tc.post
}
// Need to add the post to the expected deleted
var expectedDeleted []PostExport
for _, s := range tc.expectedDeleteFileMessages {
s.MessageExport = tc.post
expectedDeleted = append(expectedDeleted, s)
}
assert.Equal(t, tc.expectedStarts, uploadStarts)
assert.Equal(t, tc.expectedStops, uploadStops)
assert.Equal(t, tc.expectedFileInfos, files)
assert.Equal(t, expectedDeleted, deleteFileMessages)
})
}
}
func TestGetJoinLeavePosts(t *testing.T) {
mockStore := &storetest.Store{}
defer mockStore.AssertExpectations(t)
// This would have been retrieved during CalculateChannelExports
channelMemberHistories := map[string][]*model.ChannelMemberHistoryResult{
"good-request-1": {
{JoinTime: 1, UserId: "test1", UserEmail: "test1", Username: "test1"},
{JoinTime: 2, LeaveTime: new(int64(3)), UserId: "test2", UserEmail: "test2", Username: "test2"},
{JoinTime: 3, UserId: "test3", UserEmail: "test3", Username: "test3"},
},
"good-request-2": {
{JoinTime: 4, UserId: "test4", UserEmail: "test4", Username: "test4"},
{JoinTime: 5, LeaveTime: new(int64(6)), UserId: "test5", UserEmail: "test5", Username: "test5"},
{JoinTime: 6, UserId: "test6", UserEmail: "test6", Username: "test6"},
},
}
var joins []JoinExport
var leaves []LeaveExport
for _, id := range []string{"good-request-1", "good-request-2"} {
newJoins, newLeaves := getJoinsAndLeaves(
1,
7,
channelMemberHistories[id],
nil,
)
joins = append(joins, newJoins...)
leaves = append(leaves, newLeaves...)
}
assert.Len(t, joins, 6)
assert.Equal(t, JoinExport{
UserId: "test1",
Username: "test1",
UserEmail: "test1",
UserType: User,
JoinTime: 1,
LeaveTime: 7,
}, joins[0])
assert.Equal(t, JoinExport{
UserId: "test2",
Username: "test2",
UserEmail: "test2",
UserType: User,
JoinTime: 2,
LeaveTime: 3,
}, joins[1])
assert.Equal(t, JoinExport{
UserId: "test3",
Username: "test3",
UserEmail: "test3",
UserType: User,
JoinTime: 3,
LeaveTime: 7,
}, joins[2])
assert.Equal(t, JoinExport{
UserId: "test4",
Username: "test4",
UserEmail: "test4",
UserType: User,
JoinTime: 4,
LeaveTime: 7,
}, joins[3])
assert.Equal(t, JoinExport{
UserId: "test5",
Username: "test5",
UserEmail: "test5",
UserType: User,
JoinTime: 5,
LeaveTime: 6,
}, joins[4])
assert.Equal(t, JoinExport{
UserId: "test6",
Username: "test6",
UserEmail: "test6",
UserType: User,
JoinTime: 6,
LeaveTime: 7,
}, joins[5])
// remember that getJoinsAndLeaves sorts _for each channel_
assert.Len(t, leaves, 6)
// 1st channel:
assert.Equal(t, LeaveExport{
UserId: "test2",
Username: "test2",
UserEmail: "test2",
UserType: User,
LeaveTime: 3,
}, leaves[0])
assert.Equal(t, LeaveExport{
UserId: "test1",
Username: "test1",
UserEmail: "test1",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[1])
assert.Equal(t, LeaveExport{
UserId: "test3",
Username: "test3",
UserEmail: "test3",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[2])
// 2nd channel:
assert.Equal(t, LeaveExport{
UserId: "test5",
Username: "test5",
UserEmail: "test5",
UserType: User,
LeaveTime: 6,
}, leaves[3])
assert.Equal(t, LeaveExport{
UserId: "test4",
Username: "test4",
UserEmail: "test4",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[4])
assert.Equal(t, LeaveExport{
UserId: "test6",
Username: "test6",
UserEmail: "test6",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[5])
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
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,613 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"encoding/json"
"fmt"
"path"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
MissingFileMessageDuringBackendRead = "File backend read: File missing for post; cannot copy file to archive"
MissingFileMessageDuringCopy = "Copy buffer: File missing for post; cannot copy file to archive"
EstimatedPostCount = 10_000_000
// JobDataBatchStartTime is the posts.updateat value from the previous batch. Posts are selected using
// keyset pagination sorted by (posts.updateat, posts.id).
JobDataBatchStartTime = "batch_start_time"
// JobDataJobStartTime is the start of the job (doesn't change across batches)
JobDataJobStartTime = "job_start_time"
// JobDataBatchStartId is the posts.id value from the previous batch.
JobDataBatchStartId = "batch_start_id"
// JobDataJobEndTime is the point up to which this job is exporting. It is the time the job was started,
// i.e., we export everything from the end of previous batch to the moment this batch started.
JobDataJobEndTime = "job_end_time"
JobDataJobStartId = "job_start_id"
JobDataExportType = "export_type"
JobDataInitiatedBy = "initiated_by"
JobDataBatchSize = "batch_size"
JobDataChannelBatchSize = "channel_batch_size"
JobDataChannelHistoryBatchSize = "channel_history_batch_size"
JobDataMessagesExported = "messages_exported"
JobDataWarningCount = "warning_count"
JobDataIsDownloadable = "is_downloadable"
JobDataExportDir = "export_dir"
JobDataBatchNumber = "job_batch_number"
JobDataTotalPostsExpected = "total_posts_expected"
)
type PostUpdatedType string
const (
EditedOriginalMsg PostUpdatedType = "EditedOriginalMsg"
EditedNewMsg PostUpdatedType = "EditedNewMsg"
UpdatedNoMsgChange PostUpdatedType = "UpdatedNoMsgChange"
Deleted PostUpdatedType = "Deleted"
FileDeleted PostUpdatedType = "FileDeleted"
)
// JobData keeps the current state of the job.
// When used by a worker, all fields in JobDataExported are exported to the job's job.Data prop bag.
type JobData struct {
JobDataExported
ExportPeriodStartTime int64
// This section is the current state of the export
ChannelMetadata map[string]*MetadataChannel
ChannelMemberHistories map[string][]*model.ChannelMemberHistoryResult
Cursor model.MessageExportCursor
PostsToExport []*model.MessageExport
BatchEndTime int64
BatchPath string
MessageExportMs []int64
ProcessingPostsMs []int64
ProcessingXmlMs []int64
TransferringFilesMs []int64
TransferringZipMs []int64
TotalBatchMs []int64
Finished bool
}
type JobDataExported struct {
ExportType string
ExportDir string
BatchStartTime int64
BatchStartId string
JobStartTime int64
JobEndTime int64
JobStartId string
BatchSize int
ChannelBatchSize int
ChannelHistoryBatchSize int
BatchNumber int
TotalPostsExpected int
MessagesExported int
WarningCount int
IsDownloadable bool
}
func JobDataToStringMap(jd JobData) map[string]string {
ret := make(map[string]string)
ret[JobDataExportType] = jd.ExportType
ret[JobDataExportDir] = jd.ExportDir
ret[JobDataBatchStartTime] = strconv.FormatInt(jd.BatchStartTime, 10)
ret[JobDataBatchStartId] = jd.BatchStartId
ret[JobDataJobStartTime] = strconv.FormatInt(jd.JobStartTime, 10)
ret[JobDataJobEndTime] = strconv.FormatInt(jd.JobEndTime, 10)
ret[JobDataJobStartId] = jd.JobStartId
ret[JobDataBatchSize] = strconv.Itoa(jd.BatchSize)
ret[JobDataChannelBatchSize] = strconv.Itoa(jd.ChannelBatchSize)
ret[JobDataChannelHistoryBatchSize] = strconv.Itoa(jd.ChannelHistoryBatchSize)
ret[JobDataBatchNumber] = strconv.Itoa(jd.BatchNumber)
ret[JobDataTotalPostsExpected] = strconv.Itoa(jd.TotalPostsExpected)
ret[JobDataMessagesExported] = strconv.Itoa(jd.MessagesExported)
ret[JobDataWarningCount] = strconv.Itoa(jd.WarningCount)
ret[JobDataIsDownloadable] = strconv.FormatBool(jd.IsDownloadable)
return ret
}
func StringMapToJobDataWithZeroValues(sm map[string]string) (JobData, error) {
var jd JobData
var err error
jd.ExportType = sm[JobDataExportType]
jd.ExportDir = sm[JobDataExportDir]
batchStartTime, ok := sm[JobDataBatchStartTime]
if !ok {
batchStartTime = "0"
}
if jd.BatchStartTime, err = strconv.ParseInt(batchStartTime, 10, 64); err != nil {
return jd, errors.Wrap(err, "error converting JobDataBatchStartTime")
}
jd.BatchStartId = sm[JobDataBatchStartId]
jobStartTime, ok := sm[JobDataJobStartTime]
if !ok {
jobStartTime = "0"
}
if jd.JobStartTime, err = strconv.ParseInt(jobStartTime, 10, 64); err != nil {
return jd, errors.Wrap(err, "error converting JobDataJobStartTime")
}
jobEndTime, ok := sm[JobDataJobEndTime]
if !ok {
jobEndTime = "0"
}
if jd.JobEndTime, err = strconv.ParseInt(jobEndTime, 10, 64); err != nil {
return jd, errors.Wrap(err, "error converting JobDataJobEndTime")
}
jd.JobStartId = sm[JobDataJobStartId]
jobBatchSize, ok := sm[JobDataBatchSize]
if !ok {
jobBatchSize = "0"
}
if jd.BatchSize, err = strconv.Atoi(jobBatchSize); err != nil {
return jd, errors.Wrap(err, "error converting JobDataBatchSize")
}
channelBatchSize, ok := sm[JobDataChannelBatchSize]
if !ok {
channelBatchSize = "0"
}
if jd.ChannelBatchSize, err = strconv.Atoi(channelBatchSize); err != nil {
return jd, errors.Wrap(err, "error converting JobDataChannelBatchSize")
}
channelHistoryBatchSize, ok := sm[JobDataChannelHistoryBatchSize]
if !ok {
channelHistoryBatchSize = "0"
}
if jd.ChannelHistoryBatchSize, err = strconv.Atoi(channelHistoryBatchSize); err != nil {
return jd, errors.Wrap(err, "error converting JobDataChannelHistoryBatchSize")
}
batchNumber, ok := sm[JobDataBatchNumber]
if !ok {
batchNumber = "0"
}
if jd.BatchNumber, err = strconv.Atoi(batchNumber); err != nil {
return jd, errors.Wrap(err, "error converting JobDataBatchNumber")
}
totalPostsExpected, ok := sm[JobDataTotalPostsExpected]
if !ok {
totalPostsExpected = "0"
}
if jd.TotalPostsExpected, err = strconv.Atoi(totalPostsExpected); err != nil {
return jd, errors.Wrap(err, "error converting JobDataTotalPostsExpected")
}
messagesExported, ok := sm[JobDataMessagesExported]
if !ok {
messagesExported = "0"
}
if jd.MessagesExported, err = strconv.Atoi(messagesExported); err != nil {
return jd, errors.Wrap(err, "error converting JobDataMessagesExported")
}
warningCount, ok := sm[JobDataWarningCount]
if !ok {
warningCount = "0"
}
if jd.WarningCount, err = strconv.Atoi(warningCount); err != nil {
return jd, errors.Wrap(err, "error converting JobDataWarningCount")
}
isDownloadable, ok := sm[JobDataIsDownloadable]
if !ok {
isDownloadable = "0"
}
if jd.IsDownloadable, err = strconv.ParseBool(isDownloadable); err != nil {
return jd, errors.Wrap(err, "error converting JobDataIsDownloadable")
}
return jd, nil
}
type BackendParams struct {
Config *model.Config
Store MessageExportStore
FileAttachmentBackend filestore.FileBackend
ExportBackend filestore.FileBackend
HtmlTemplates *templates.Container
}
type ExportParams struct {
ExportType string
ChannelMetadata map[string]*MetadataChannel
Posts []*model.MessageExport
ChannelMemberHistories map[string][]*model.ChannelMemberHistoryResult
JobStartTime int64
BatchPath string
BatchStartTime int64
BatchEndTime int64
Config *model.Config
Db MessageExportStore
FileAttachmentBackend filestore.FileBackend
ExportBackend filestore.FileBackend
Templates *templates.Container
}
type WriteExportResult struct {
TransferringFilesMs int64
ProcessingXmlMs int64
TransferringZipMs int64
NumWarnings int
}
type RunExportResults struct {
CreatedPosts int
EditedOrigMsgPosts int
EditedNewMsgPosts int
UpdatedPosts int
DeletedPosts int
UploadedFiles int
DeletedFiles int
NumChannels int
Joins int
Leaves int
ProcessingPostsMs int64
WriteExportResult
}
type ChannelMemberJoin struct {
UserId string
IsBot bool
Email string
Username string
Datetime int64
}
type ChannelMemberLeave struct {
UserId string
IsBot bool
Email string
Username string
Datetime int64
}
type ChannelMember struct {
UserId string
IsBot bool
Email string
Username string
}
type MetadataChannel struct {
TeamId *string
TeamName *string
TeamDisplayName *string
ChannelId string
ChannelName string
ChannelDisplayName string
ChannelType model.ChannelType
RoomId string
StartTime int64
EndTime int64
MessagesCount int
AttachmentsCount int
}
type Metadata struct {
Channels map[string]*MetadataChannel
MessagesCount int
AttachmentsCount int
StartTime int64
EndTime int64
}
func (metadata *Metadata) UpdateCounts(channelId string, numMessages int, numAttachments int) error {
_, ok := metadata.Channels[channelId]
if !ok {
return fmt.Errorf("could not find channelId for post in metadata.Channels")
}
metadata.Channels[channelId].AttachmentsCount += numAttachments
metadata.AttachmentsCount += numAttachments
metadata.Channels[channelId].MessagesCount += numMessages
metadata.MessagesCount += numMessages
return nil
}
// GetInitialExportPeriodData calculates and caches the channel memberships, channel metadata, and the TotalPostsExpected.
func GetInitialExportPeriodData(rctx request.CTX, store MessageExportStore, data JobData, reportProgress func(string)) (JobData, error) {
// Counting all posts may fail or timeout when the posts table is large. If this happens, log a warning, but carry
// on with the job anyway. The only issue is that the progress % reporting will be inaccurate.
count, err := store.Post().AnalyticsPostCount(&model.PostCountOptions{ExcludeSystemPosts: true, SincePostID: data.JobStartId, SinceUpdateAt: data.ExportPeriodStartTime, UntilUpdateAt: data.JobEndTime})
if err != nil {
rctx.Logger().Warn("Worker: Failed to fetch total post count for job. An estimated value will be used for progress reporting.", mlog.Err(err))
data.TotalPostsExpected = EstimatedPostCount
} else {
data.TotalPostsExpected = int(count)
}
rctx.Logger().Info("Expecting to export total posts", mlog.Int("total_posts", data.TotalPostsExpected))
// Every time we claim the job, we need to gather the membership data that every batch will use.
// If we're here, then either this is the start of the job, or the job was stopped (e.g., the worker stopped)
// and we've claimed it again. Either way, we need to recalculate channel and member history data.
data.ChannelMetadata, data.ChannelMemberHistories, err = CalculateChannelExports(rctx,
ChannelExportsParams{
Store: store,
ExportPeriodStartTime: data.ExportPeriodStartTime,
ExportPeriodEndTime: data.JobEndTime,
ChannelBatchSize: data.ChannelBatchSize,
ChannelHistoryBatchSize: data.ChannelHistoryBatchSize,
ReportProgressMessage: reportProgress,
})
if err != nil {
return data, err
}
data.Cursor = model.MessageExportCursor{
LastPostUpdateAt: data.BatchStartTime,
LastPostId: data.BatchStartId,
UntilUpdateAt: data.JobEndTime,
}
return data, nil
}
type ChannelExportsParams struct {
Store MessageExportStore
ExportPeriodStartTime int64
ExportPeriodEndTime int64
ChannelBatchSize int
ChannelHistoryBatchSize int
ReportProgressMessage func(message string)
}
// CalculateChannelExports returns the channel info ( map[channelId]*MetadataChannel ) and the channel user
// joins/leaves ( map[channelId][]*model.ChannelMemberHistoryResult ) for any channel that has had activity
// (posts or user join/leaves) between ExportPeriodStartTime and ExportPeriodEndTime.
func CalculateChannelExports(rctx request.CTX, opt ChannelExportsParams) (map[string]*MetadataChannel, map[string][]*model.ChannelMemberHistoryResult, error) {
// Which channels had user activity in the export period?
activeChannelIds, err := opt.Store.ChannelMemberHistory().GetChannelsWithActivityDuring(opt.ExportPeriodStartTime, opt.ExportPeriodEndTime)
if err != nil {
return nil, nil, err
}
if len(activeChannelIds) == 0 {
return nil, nil, nil
}
rctx.Logger().Debug("Started CalculateChannelExports", mlog.Int("export_period_start_time", opt.ExportPeriodStartTime), mlog.Int("export_period_end_time", opt.ExportPeriodEndTime), mlog.Int("num_active_channel_ids", len(activeChannelIds)))
message := rctx.T("ent.message_export.actiance_export.calculate_channel_exports.channel_message", model.StringMap{"NumChannels": strconv.Itoa(len(activeChannelIds))})
opt.ReportProgressMessage(message)
// For each channel, get its metadata.
channelMetadata := make(map[string]*MetadataChannel, len(activeChannelIds))
// Use batches to reduce db load and network waste.
for pos := 0; pos < len(activeChannelIds); pos += opt.ChannelBatchSize {
upTo := min(pos+opt.ChannelBatchSize, len(activeChannelIds))
batch := activeChannelIds[pos:upTo]
channels, err := opt.Store.Channel().GetMany(batch, true)
if err != nil {
return nil, nil, err
}
for _, channel := range channels {
channelMetadata[channel.Id] = &MetadataChannel{
TeamId: new(channel.TeamId),
ChannelId: channel.Id,
ChannelName: channel.Name,
ChannelDisplayName: channel.DisplayName,
ChannelType: channel.Type,
RoomId: fmt.Sprintf("%v - %v", ChannelTypeDisplayName(channel.Type), channel.Id),
StartTime: opt.ExportPeriodStartTime,
EndTime: opt.ExportPeriodEndTime,
}
}
}
historiesByChannelId := make(map[string][]*model.ChannelMemberHistoryResult, len(activeChannelIds))
var batchTimes []int64
// Now that we have metadata, get channelMemberHistories for each channel.
// Use batches to reduce total db load and network waste.
for pos := 0; pos < len(activeChannelIds); pos += opt.ChannelHistoryBatchSize {
// This may take a while, so update the system console UI.
message := rctx.T("ent.message_export.actiance_export.calculate_channel_exports.activity_message", model.StringMap{
"NumChannels": strconv.Itoa(len(activeChannelIds)),
"NumCompleted": strconv.Itoa(pos),
})
opt.ReportProgressMessage(message)
start := time.Now()
upTo := min(pos+opt.ChannelHistoryBatchSize, len(activeChannelIds))
batch := activeChannelIds[pos:upTo]
channelMemberHistories, err := opt.Store.ChannelMemberHistory().GetUsersInChannelDuring(opt.ExportPeriodStartTime, opt.ExportPeriodEndTime, batch)
if err != nil {
return nil, nil, err
}
batchTimes = append(batchTimes, time.Since(start).Milliseconds())
// collect the channelMemberHistories by channelId
for _, entry := range channelMemberHistories {
historiesByChannelId[entry.ChannelId] = append(historiesByChannelId[entry.ChannelId], entry)
}
}
rctx.Logger().Info("GetUsersInChannelDuring batch times", mlog.Array("batch_times", batchTimes))
return channelMetadata, historiesByChannelId, nil
}
// ChannelHasActivity returns true if the channel (represented by the []*model.ChannelMemberHistoryResult slice)
// had user activity between startTime and endTime
func ChannelHasActivity(cmhs []*model.ChannelMemberHistoryResult, startTime int64, endTime int64) bool {
for _, cmh := range cmhs {
if (cmh.JoinTime >= startTime && cmh.JoinTime <= endTime) ||
(cmh.LeaveTime != nil && *cmh.LeaveTime >= startTime && *cmh.LeaveTime <= endTime) {
return true
}
}
return false
}
func GetJoinsAndLeavesForChannel(startTime int64, endTime int64, channelMembersHistory []*model.ChannelMemberHistoryResult,
postAuthors map[string]ChannelMember) ([]ChannelMemberJoin, []ChannelMemberLeave) {
var joins []ChannelMemberJoin
var leaves []ChannelMemberLeave
alreadyJoined := make(map[string]bool)
for _, cmh := range channelMembersHistory {
if cmh.UserDeleteAt > 0 && cmh.UserDeleteAt < startTime {
continue
}
if cmh.JoinTime > endTime {
continue
}
if cmh.LeaveTime != nil && *cmh.LeaveTime < startTime {
continue
}
if cmh.JoinTime <= endTime {
joins = append(joins, ChannelMemberJoin{
UserId: cmh.UserId,
IsBot: cmh.IsBot,
Email: cmh.UserEmail,
Username: cmh.Username,
Datetime: cmh.JoinTime,
})
alreadyJoined[cmh.UserId] = true
}
if cmh.LeaveTime != nil && *cmh.LeaveTime <= endTime {
leaves = append(leaves, ChannelMemberLeave{
UserId: cmh.UserId,
IsBot: cmh.IsBot,
Email: cmh.UserEmail,
Username: cmh.Username,
Datetime: *cmh.LeaveTime,
})
}
}
for _, member := range postAuthors {
if alreadyJoined[member.UserId] {
continue
}
joins = append(joins, ChannelMemberJoin{
UserId: member.UserId,
IsBot: member.IsBot,
Email: member.Email,
Username: member.Username,
Datetime: startTime,
})
}
return joins, leaves
}
// GetPostAttachments if the post included any files, we need to add special elements to the export.
func GetPostAttachments(db MessageExportStore, post *model.MessageExport) ([]*model.FileInfo, error) {
if len(post.PostFileIds) == 0 {
return []*model.FileInfo{}, nil
}
attachments, err := db.FileInfo().GetForPost(*post.PostId, true, true, false)
if err != nil {
return nil, fmt.Errorf("failed to get file info for a post: %w", err)
}
return attachments, nil
}
func ChannelTypeDisplayName(channelType model.ChannelType) string {
return map[model.ChannelType]string{
model.ChannelTypeOpen: "public",
model.ChannelTypePrivate: "private",
model.ChannelTypeDirect: "direct",
model.ChannelTypeGroup: "group",
}[channelType]
}
func GetBatchPath(exportDir string, prevPostUpdateAt int64, lastPostUpdateAt int64, batchNumber int) string {
if exportDir == "" {
exportDir = path.Join(model.ComplianceExportPath, time.Now().Format(model.ComplianceExportDirectoryFormat))
}
return path.Join(exportDir,
fmt.Sprintf("batch%03d-%d-%d.zip", batchNumber, prevPostUpdateAt, lastPostUpdateAt))
}
// GetExportBackend returns the file backend where the export will be created.
func GetExportBackend(rctx request.CTX, config *model.Config) (filestore.FileBackend, error) {
insecure := config.ServiceSettings.EnableInsecureOutgoingConnections
skipVerify := insecure != nil && *insecure
allowedUntrustedInternalConnections := model.SafeDereference(config.ServiceSettings.AllowedUntrustedInternalConnections)
if config.FileSettings.DedicatedExportStore != nil && *config.FileSettings.DedicatedExportStore {
rctx.Logger().Debug("Worker: using dedicated export filestore", mlog.String("driver_name", *config.FileSettings.ExportDriverName))
backend, errFileBack := filestore.NewExportFileBackend(filestore.NewExportFileBackendSettingsFromConfig(&config.FileSettings, true, skipVerify, allowedUntrustedInternalConnections))
if errFileBack != nil {
return nil, errFileBack
}
return backend, nil
}
backend, err := filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(&config.FileSettings, true, skipVerify, allowedUntrustedInternalConnections))
if err != nil {
return nil, err
}
return backend, nil
}
// GetFileAttachmentBackend returns the file backend where file attachments are
// located for messages that will be exported. This may be the same backend
// where the export will be created.
func GetFileAttachmentBackend(rctx request.CTX, config *model.Config) (filestore.FileBackend, error) {
insecure := config.ServiceSettings.EnableInsecureOutgoingConnections
allowedUntrustedInternalConnections := model.SafeDereference(config.ServiceSettings.AllowedUntrustedInternalConnections)
backend, err := filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(&config.FileSettings, true, insecure != nil && *insecure, allowedUntrustedInternalConnections))
if err != nil {
return nil, err
}
return backend, nil
}
func IsDeletedMsg(post *model.MessageExport) bool {
if model.SafeDereference(post.PostDeleteAt) > 0 && post.PostProps != nil {
props := map[string]any{}
err := json.Unmarshal([]byte(*post.PostProps), &props)
if err != nil {
return false
}
if _, ok := props[model.PostPropsDeleteBy]; ok {
return true
}
}
return false
}

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetJoinsAndLeavesForChannel(t *testing.T) {
channel := MetadataChannel{
StartTime: 100,
EndTime: 200,
ChannelId: "good-request-1",
TeamId: new("test"),
TeamName: new("test"),
TeamDisplayName: new("test"),
ChannelName: "test",
ChannelDisplayName: "test",
ChannelType: "O",
}
tt := []struct {
name string
channel MetadataChannel
membersHistory []*model.ChannelMemberHistoryResult
usersInPosts map[string]ChannelMember
expectedJoins int
expectedLeaves int
}{
{
name: "no-joins-no-leaves",
channel: channel,
membersHistory: nil,
usersInPosts: nil,
expectedJoins: 0,
expectedLeaves: 0,
},
{
name: "joins-and-leaves-outside-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 1, LeaveTime: new(int64(10)), UserId: "test", UserEmail: "test", Username: "test"},
{JoinTime: 250, LeaveTime: new(int64(260)), UserId: "test", UserEmail: "test", Username: "test"},
{JoinTime: 300, UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 0,
expectedLeaves: 0,
},
{
name: "join-and-leave-during-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 100, LeaveTime: new(int64(150)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 1,
},
{
name: "join-during-and-leave-after-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 150, LeaveTime: new(int64(300)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 0,
},
{
name: "join-before-and-leave-during-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 99, LeaveTime: new(int64(150)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 1,
},
{
name: "join-before-and-leave-after-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 99, LeaveTime: new(int64(350)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 0,
},
{
name: "implicit-joins",
channel: channel,
membersHistory: nil,
usersInPosts: map[string]ChannelMember{
"test1": {UserId: "test1", Email: "test1", Username: "test1"},
"test2": {UserId: "test2", Email: "test2", Username: "test2"},
},
expectedJoins: 2,
expectedLeaves: 0,
},
{
name: "implicit-joins-with-explicit-joins",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: new(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1"},
{JoinTime: 130, LeaveTime: new(int64(150)), UserId: "test3", UserEmail: "test3", Username: "test3"},
},
usersInPosts: map[string]ChannelMember{
"test1": {UserId: "test1", Email: "test1", Username: "test1"},
"test2": {UserId: "test2", Email: "test2", Username: "test2"},
},
expectedJoins: 3,
expectedLeaves: 2,
},
{
name: "join-leave-and-join-again",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: new(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1"},
{JoinTime: 160, LeaveTime: new(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1"},
},
usersInPosts: nil,
expectedJoins: 2,
expectedLeaves: 2,
},
{
name: "deactivated-members-dont-show",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: new(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 50},
{JoinTime: 160, LeaveTime: new(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 50},
},
usersInPosts: nil,
expectedJoins: 0,
expectedLeaves: 0,
},
{
name: "deactivated-members-show-if-deleted-after-latest-export",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: new(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 150},
{JoinTime: 160, LeaveTime: new(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 150},
},
usersInPosts: nil,
expectedJoins: 2,
expectedLeaves: 2,
},
{
name: "deactivated-members-show-and-dont-show",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: new(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 50},
{JoinTime: 160, LeaveTime: new(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 150},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 1,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
joins, leaves := GetJoinsAndLeavesForChannel(tc.channel.StartTime, tc.channel.EndTime, tc.membersHistory, tc.usersInPosts)
assert.Len(t, joins, tc.expectedJoins)
assert.Len(t, leaves, tc.expectedLeaves)
})
}
}
func Test_GetBatchPath(t *testing.T) {
tests := []struct {
name string
exportDir string
prevPostUpdateAt int64
lastPostUpdateAt int64
batchNumber int
want string
}{
{
name: "all args given",
exportDir: "/export/test_dir",
prevPostUpdateAt: 123,
lastPostUpdateAt: 456,
batchNumber: 21,
want: "/export/test_dir/batch021-123-456.zip",
},
{
name: "exportDir blank",
exportDir: "",
prevPostUpdateAt: 12345,
lastPostUpdateAt: 456789,
batchNumber: 921,
want: model.ComplianceExportPath + "/" + time.Now().Format(model.ComplianceExportDirectoryFormat) + "/batch921-12345-456789.zip",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, GetBatchPath(tt.exportDir, tt.prevPostUpdateAt, tt.lastPostUpdateAt, tt.batchNumber), "GetBatchPath(%v, %v, %v, %v)", tt.exportDir, tt.prevPostUpdateAt, tt.lastPostUpdateAt, tt.batchNumber)
})
}
}
func TestJobDataToStringMap_and_StringMapToJobData(t *testing.T) {
jd := JobData{
JobDataExported: JobDataExported{
ExportType: "cli_message_export",
ExportDir: "/here/there/34234-123",
BatchStartTime: 45,
BatchStartId: "34arsitenaorsten",
JobStartTime: 99,
JobEndTime: 1234,
JobStartId: "99abcdef34",
BatchSize: 2000,
ChannelBatchSize: 30000,
ChannelHistoryBatchSize: 30,
BatchNumber: 4,
TotalPostsExpected: 999999,
MessagesExported: 343499,
WarningCount: 39,
},
ExportPeriodStartTime: 123456, // not exported
BatchEndTime: 999999999, // not exported
BatchPath: "/another/path/123-123", // not exported
}
strMap := JobDataToStringMap(jd)
expected := make(map[string]string)
expected[JobDataExportType] = "cli_message_export"
expected[JobDataExportDir] = "/here/there/34234-123"
expected[JobDataBatchStartTime] = "45"
expected[JobDataBatchStartId] = "34arsitenaorsten"
expected[JobDataJobStartTime] = "99"
expected[JobDataJobEndTime] = "1234"
expected[JobDataJobStartId] = "99abcdef34"
expected[JobDataBatchSize] = "2000"
expected[JobDataChannelBatchSize] = "30000"
expected[JobDataChannelHistoryBatchSize] = "30"
expected[JobDataBatchNumber] = "4"
expected[JobDataTotalPostsExpected] = "999999"
expected[JobDataMessagesExported] = "343499"
expected[JobDataWarningCount] = "39"
expected[JobDataIsDownloadable] = "false"
for k, v := range expected {
val, ok := strMap[k]
assert.True(t, ok)
assert.Equal(t, v, val)
}
// not exported:
for _, k := range []string{"export_period_start_time", "batch_end_time", "batch_path"} {
_, ok := strMap[k]
assert.False(t, ok)
}
// zero the fields that weren't exported:
jd.ExportPeriodStartTime = 0
jd.BatchEndTime = 0
jd.BatchPath = ""
// now convert back
jd2, err := StringMapToJobDataWithZeroValues(strMap)
assert.NoError(t, err)
assert.Equal(t, jd, jd2)
// and test bad conversion (just a couple):
badStrMap := map[string]string{JobDataJobStartTime: "56aaa"}
_, err = StringMapToJobDataWithZeroValues(badStrMap)
assert.Error(t, err)
badStrMap = map[string]string{JobDataJobEndTime: "blah blah"}
_, err = StringMapToJobDataWithZeroValues(badStrMap)
assert.Error(t, err)
// test that zero values are used when not present
emptyStrMap := make(map[string]string)
emptyJd, err := StringMapToJobDataWithZeroValues(emptyStrMap)
assert.NoError(t, err)
assert.Equal(t, JobData{}, emptyJd)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type MessageExportStore interface {
Post() store.PostStore
ChannelMemberHistory() store.ChannelMemberHistoryStore
Channel() store.ChannelStore
Compliance() store.ComplianceStore
FileInfo() MEFileInfoStore
}
type MEFileInfoStore interface {
GetForPost(postID string, readFromMaster, includeDeleted, allowFromCache bool) ([]*model.FileInfo, error)
}
type messageExportStore struct {
store.Store
}
// NewMessageExportStore returns a wrapped store.Store. Why? Because the CLI tool needs to "override" the GetForPost
// method in FileStore. Non-CLI code only needs to use the store.Store's methods. This can be removed when we deprecate
// the CLI tool. Tracked at MM-61139.
func NewMessageExportStore(s store.Store) messageExportStore {
return messageExportStore{s}
}
func (ss messageExportStore) FileInfo() MEFileInfoStore {
return ss.Store.FileInfo()
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package metrics
import (
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/prometheus/client_golang/prometheus"
)
// DynamicCounter provides a CounterVec that can create new counters via label values at runtime.
// This allows applications to create counters at runtime without locking into Prometheus as the
// metrics manager.
type DynamicCounter struct {
counter *prometheus.CounterVec
}
// NewDynamicCounter creates a new dynamic counter with corresponding labels.
func NewDynamicCounter(opts prometheus.CounterOpts, labels ...string) *DynamicCounter {
return &DynamicCounter{
counter: prometheus.NewCounterVec(opts, labels),
}
}
// GetCounter fetches a counter associated with the label values provided. An error is
// returned if the number of values differs from the number of labels provided when
// creating the DynamicCounter
func (dc *DynamicCounter) GetCounter(values ...string) (prometheus.Counter, error) {
return dc.counter.GetMetricWithLabelValues(values...)
}
// DynamicGauge provides a GaugeVec that can create new gauges via label values at runtime.
// This allows applications to create gauges at runtime without locking into Prometheus as the
// metrics manager.
type DynamicGauge struct {
gauge *prometheus.GaugeVec
}
// NewDynamicGauge creates a new dynamic gauge with corresponding labels.
func NewDynamicGauge(opts prometheus.GaugeOpts, labels ...string) *DynamicGauge {
return &DynamicGauge{
gauge: prometheus.NewGaugeVec(opts, labels),
}
}
// GetGauge fetches a gauge associated with the label values provided. An error is
// returned if the number of values differs from the number of labels provided when
// creating the DynamicGauge
func (dg *DynamicGauge) GetGauge(values ...string) (prometheus.Gauge, error) {
return dg.gauge.GetMetricWithLabelValues(values...)
}
// LoggerMetricsCollector provides counters for server logging.
// Implements Logr.MetricsCollector
type LoggerMetricsCollector struct {
queueGauge *DynamicGauge
loggedCounters *DynamicCounter
errorCounters *DynamicCounter
droppedCounters *DynamicCounter
blockedCounters *DynamicCounter
}
func (c *LoggerMetricsCollector) QueueSizeGauge(target string) (mlog.Gauge, error) {
return c.queueGauge.GetGauge(target)
}
func (c *LoggerMetricsCollector) LoggedCounter(target string) (mlog.Counter, error) {
return c.loggedCounters.GetCounter(target)
}
func (c *LoggerMetricsCollector) ErrorCounter(target string) (mlog.Counter, error) {
return c.errorCounters.GetCounter(target)
}
func (c *LoggerMetricsCollector) DroppedCounter(target string) (mlog.Counter, error) {
return c.droppedCounters.GetCounter(target)
}
func (c *LoggerMetricsCollector) BlockedCounter(target string) (mlog.Counter, error) {
return c.blockedCounters.GetCounter(target)
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// HistogramVec is a wrapper of prometheus.HistogramVec that stores the buckets
// which are later passed down to WrappedObserver.
type HistogramVec struct {
*prometheus.HistogramVec
buckets []float64
fqName string
logger mlog.LoggerIFace
}
// WrappedObserver is a wrapper of prometheus.Observer which addtionally
// logs userIDs when the value exceeds or equals to the one in the highest bucket.
type WrappedObserver struct {
prometheus.Observer
labels prometheus.Labels
buckets []float64
logger mlog.LoggerIFace
metricName string
userID string
}
// NewHistogramVec returns a custom-purpose HistogramVec.
func NewHistogramVec(opts prometheus.HistogramOpts, labelNames []string, logger mlog.LoggerIFace) *HistogramVec {
if len(opts.Buckets) == 0 {
opts.Buckets = prometheus.DefBuckets
}
return &HistogramVec{
HistogramVec: prometheus.NewHistogramVec(opts, labelNames),
buckets: opts.Buckets,
fqName: prometheus.BuildFQName(opts.Namespace, opts.Subsystem, opts.Name),
logger: logger,
}
}
func (v *HistogramVec) With(labels prometheus.Labels, userID string) prometheus.Observer {
h, err := v.GetMetricWith(labels)
if err != nil {
panic(err)
}
return &WrappedObserver{
Observer: h,
labels: labels,
buckets: v.buckets,
logger: v.logger,
metricName: v.fqName,
userID: userID,
}
}
func (o *WrappedObserver) Observe(v float64) {
if v >= o.buckets[len(o.buckets)-1] {
fields := []mlog.Field{
mlog.String("metric_name", o.metricName),
mlog.String("user_id", o.userID),
mlog.Float("observed_value", v),
mlog.Float("highest_bucket_value", o.buckets[len(o.buckets)-1]),
}
for k, v := range o.labels {
fields = append(fields, mlog.String(k, v))
}
o.logger.Warn("Metric observation exceeded.", fields...)
}
o.Observer.Observe(v)
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package metrics
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
func TestWrappedObserver(t *testing.T) {
th := api4.Setup(t)
h := NewHistogramVec(prometheus.HistogramOpts{
Namespace: MetricsNamespace,
Subsystem: MetricsSubsystemClientsWeb,
Name: "test",
Buckets: []float64{0, 5, 10},
}, []string{"l1"}, th.TestLogger)
h.With(prometheus.Labels{"l1": "hello"}, th.BasicUser.Id).Observe(6)
require.NoError(t, th.TestLogger.Flush())
testlib.AssertNoLog(t, th.LogBuffer, mlog.LvlWarn.Name, "Metric observation exceeded.")
h.With(prometheus.Labels{"l1": "hello"}, th.BasicUser.Id).Observe(10)
require.NoError(t, th.TestLogger.Flush())
testlib.AssertLog(t, th.LogBuffer, mlog.LvlWarn.Name, "Metric observation exceeded.")
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package metrics
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,401 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package metrics
import (
"fmt"
"strconv"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/app"
seMocks "github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/prometheus/client_golang/prometheus"
prometheusModels "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
)
func configureMetrics(th *api4.TestHelper) {
th.App.Srv().SetLicense(nil) // clear license
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MetricsSettings.Enable = true
*cfg.MetricsSettings.ListenAddress = ":0"
})
th.App.Srv().SetLicense(model.NewTestLicense("metrics"))
}
func TestMetrics(t *testing.T) {
th := api4.SetupEnterpriseWithStoreMock(t, app.StartMetrics)
mockStore := th.App.Srv().Platform().Store.(*mocks.Store)
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)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
configureMetrics(th)
mi := th.App.Metrics()
_, ok := mi.(*MetricsInterfaceImpl)
require.True(t, ok, fmt.Sprintf("App.Metrics is not *MetricsInterfaceImpl, but %T", mi))
mi.IncrementHTTPRequest()
mi.IncrementHTTPError()
mi.IncrementPostFileAttachment(5)
mi.IncrementPostCreate()
mi.IncrementPostSentEmail()
mi.IncrementPostSentPush()
mi.IncrementPostBroadcast()
mi.IncrementLogin()
mi.IncrementLoginFail()
mi.IncrementClusterRequest()
mi.ObserveClusterRequestDuration(2.0)
mi.IncrementClusterEventType(model.ClusterEventPublish)
loggerCollector := mi.GetLoggerMetricsCollector()
g, err := loggerCollector.QueueSizeGauge("_logr")
require.NoError(t, err)
g.Set(59)
c, err := loggerCollector.LoggedCounter("_logr")
require.NoError(t, err)
c.Inc()
c, err = loggerCollector.ErrorCounter("_logr")
require.NoError(t, err)
c.Inc()
c, err = loggerCollector.DroppedCounter("_logr")
require.NoError(t, err)
c.Inc()
c, err = loggerCollector.BlockedCounter("_logr")
require.NoError(t, err)
c.Inc()
}
func TestPluginMetrics(t *testing.T) {
th := api4.SetupEnterprise(t, app.StartMetrics)
configureMetrics(th)
mi := th.App.Metrics()
miImpl, ok := mi.(*MetricsInterfaceImpl)
require.True(t, ok, fmt.Sprintf("App.Metrics is not *MetricsInterfaceImpl, but %T", mi))
t.Run("test ObservePluginHookDuration", func(t *testing.T) {
pluginID := "id_"
hookName := "hook_"
elapsed := 999.1
m := &prometheusModels.Metric{}
for _, success := range []bool{true, false} {
actualMetric, err := miImpl.PluginHookTimeHistogram.GetMetricWith(prometheus.Labels{"plugin_id": pluginID, "hook_name": hookName, "success": strconv.FormatBool(success)})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(0), m.Histogram.GetSampleCount())
require.Equal(t, 0.0, m.Histogram.GetSampleSum())
mi.ObservePluginHookDuration(pluginID, hookName, success, elapsed)
actualMetric, err = miImpl.PluginHookTimeHistogram.GetMetricWith(prometheus.Labels{"plugin_id": pluginID, "hook_name": hookName, "success": strconv.FormatBool(success)})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(1), m.Histogram.GetSampleCount())
require.InDelta(t, elapsed, m.Histogram.GetSampleSum(), 0.001)
}
})
t.Run("test ObservePluginAPIDuration", func(t *testing.T) {
pluginID := "id_"
apiName := "api_"
elapsed := 999.1
m := &prometheusModels.Metric{}
for _, success := range []bool{true, false} {
actualMetric, err := miImpl.PluginAPITimeHistogram.GetMetricWith(prometheus.Labels{"plugin_id": pluginID, "api_name": apiName, "success": strconv.FormatBool(success)})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(0), m.Histogram.GetSampleCount())
require.Equal(t, 0.0, m.Histogram.GetSampleSum())
mi.ObservePluginAPIDuration(pluginID, apiName, success, elapsed)
actualMetric, err = miImpl.PluginAPITimeHistogram.GetMetricWith(prometheus.Labels{"plugin_id": pluginID, "api_name": apiName, "success": strconv.FormatBool(success)})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(1), m.Histogram.GetSampleCount())
require.InDelta(t, elapsed, m.Histogram.GetSampleSum(), 0.001)
}
})
t.Run("test ObservePluginMultiHookIterationDuration", func(t *testing.T) {
pluginID := "id_"
elapsed := 999.1
m := &prometheusModels.Metric{}
actualMetric, err := miImpl.PluginMultiHookTimeHistogram.GetMetricWith(prometheus.Labels{"plugin_id": pluginID})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(0), m.Histogram.GetSampleCount())
require.Equal(t, 0.0, m.Histogram.GetSampleSum())
mi.ObservePluginMultiHookIterationDuration(pluginID, elapsed)
actualMetric, err = miImpl.PluginMultiHookTimeHistogram.GetMetricWith(prometheus.Labels{"plugin_id": pluginID})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(1), m.Histogram.GetSampleCount())
require.InDelta(t, elapsed, m.Histogram.GetSampleSum(), 0.001)
})
t.Run("test ObservePluginMultiHookDuration", func(t *testing.T) {
elapsed := 50.0
m := &prometheusModels.Metric{}
require.NoError(t, miImpl.PluginMultiHookServerTimeHistogram.Write(m))
require.InDelta(t, 0.0, m.Histogram.GetSampleSum(), 0.001)
mi.ObservePluginMultiHookDuration(elapsed)
require.NoError(t, miImpl.PluginMultiHookServerTimeHistogram.Write(m))
require.InDelta(t, elapsed, m.Histogram.GetSampleSum(), 0.001)
})
}
func TestMobileMetrics(t *testing.T) {
th := api4.SetupEnterprise(t, app.StartMetrics)
configureMetrics(th)
mi := th.App.Metrics()
miImpl, ok := mi.(*MetricsInterfaceImpl)
require.True(t, ok, fmt.Sprintf("App.Metrics is not *MetricsInterfaceImpl, but %T", mi))
ttcc := []struct {
name string
histogramVec *prometheus.HistogramVec
observeFunc func(string, float64)
}{
{
name: "load duration",
histogramVec: miImpl.MobileClientLoadDuration,
observeFunc: mi.ObserveMobileClientLoadDuration,
},
{
name: "channel switch duration",
histogramVec: miImpl.MobileClientChannelSwitchDuration,
observeFunc: mi.ObserveMobileClientChannelSwitchDuration,
},
{
name: "team switch duration",
histogramVec: miImpl.MobileClientTeamSwitchDuration,
observeFunc: mi.ObserveMobileClientTeamSwitchDuration,
},
}
for _, tc := range ttcc {
t.Run(tc.name, func(t *testing.T) {
m := &prometheusModels.Metric{}
elapsed := 999.1
for _, platform := range []string{"ios", "android"} {
actualMetric, err := tc.histogramVec.GetMetricWith(prometheus.Labels{"platform": platform})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(0), m.Histogram.GetSampleCount())
require.Equal(t, 0.0, m.Histogram.GetSampleSum())
tc.observeFunc(platform, elapsed)
actualMetric, err = tc.histogramVec.GetMetricWith(prometheus.Labels{"platform": platform})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(1), m.Histogram.GetSampleCount())
require.InDelta(t, elapsed, m.Histogram.GetSampleSum(), 0.001)
}
})
}
}
func TestExtractDBCluster(t *testing.T) {
testCases := []struct {
description string
driver string
connectionStr string
expectedClusterName string
}{
{
description: "postgres full",
driver: "postgres",
connectionStr: "postgres://user1234:password1234@rds-cluster-multitenant-1234-postgres.cluster-abcd.us-east-1.rds.amazonaws.com:5432/cloud?connect_timeout=10",
expectedClusterName: "rds-cluster-multitenant-1234-postgres",
},
{
description: "postgres no credentials",
driver: "postgres",
connectionStr: "postgres://rds-cluster-multitenant-1234-postgres.cluster-abcd.us-east-1.rds.amazonaws.com:5432/cloud?connect_timeout=10",
expectedClusterName: "rds-cluster-multitenant-1234-postgres",
},
}
for _, tc := range testCases {
t.Run(tc.description, func(t *testing.T) {
host, err := extractDBCluster(tc.driver, tc.connectionStr)
require.NoError(t, err)
require.Equal(t, tc.expectedClusterName, host)
})
}
}
func TestSearchEngineStatusGauge(t *testing.T) {
th := api4.SetupEnterprise(t, app.StartMetrics)
configureMetrics(th)
mi := th.App.Metrics()
miImpl, ok := mi.(*MetricsInterfaceImpl)
require.True(t, ok, fmt.Sprintf("App.Metrics is not *MetricsInterfaceImpl, but %T", mi))
readGauge := func() float64 {
m := &prometheusModels.Metric{}
require.NoError(t, miImpl.SearchEngineStatusGauge.Write(m))
return m.Gauge.GetValue()
}
setEngine := func(t *testing.T, engine *seMocks.SearchEngineInterface) {
t.Helper()
miImpl.Platform.SearchEngine.ElasticsearchEngine = engine
t.Cleanup(func() {
miImpl.Platform.SearchEngine.ElasticsearchEngine = nil
})
}
t.Run("nil engine returns 1", func(t *testing.T) {
miImpl.Platform.SearchEngine.ElasticsearchEngine = nil
require.Equal(t, 1.0, readGauge())
})
t.Run("disabled engine returns 1", func(t *testing.T) {
esMock := &seMocks.SearchEngineInterface{}
esMock.On("IsEnabled").Return(false)
setEngine(t, esMock)
require.Equal(t, 1.0, readGauge())
})
t.Run("enabled healthy engine returns 1", func(t *testing.T) {
esMock := &seMocks.SearchEngineInterface{}
esMock.On("IsEnabled").Return(true)
esMock.On("IsHealthy").Return(true)
setEngine(t, esMock)
require.Equal(t, 1.0, readGauge())
})
t.Run("enabled unhealthy engine returns 0", func(t *testing.T) {
esMock := &seMocks.SearchEngineInterface{}
esMock.On("IsEnabled").Return(true)
esMock.On("IsHealthy").Return(false)
setEngine(t, esMock)
require.Equal(t, 0.0, readGauge())
})
}
func TestObserveClusterReliableFallbackLength(t *testing.T) {
th := api4.SetupEnterprise(t, app.StartMetrics)
configureMetrics(th)
mi := th.App.Metrics()
miImpl, ok := mi.(*MetricsInterfaceImpl)
require.True(t, ok, fmt.Sprintf("App.Metrics is not *MetricsInterfaceImpl, but %T", mi))
event := model.ClusterEvent("ws_event")
length := 42
m := &prometheusModels.Metric{}
t.Run("labels are correct", func(t *testing.T) {
_, err := miImpl.ClusterReliableFallbackLength.GetMetricWith(prometheus.Labels{"event": "x"})
require.NoError(t, err, "expected 'event' to be a registered label")
_, err = miImpl.ClusterReliableFallbackLength.GetMetricWith(prometheus.Labels{"wrong_label": "x"})
require.Error(t, err, "expected no label other than 'event' to be registered")
})
t.Run("metric is registered and initialized at 0", func(t *testing.T) {
actualMetric, err := miImpl.ClusterReliableFallbackLength.GetMetricWith(prometheus.Labels{"event": string(event)})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(0), m.Histogram.GetSampleCount())
require.Equal(t, 0.0, m.Histogram.GetSampleSum())
})
t.Run("metric can be observed and the registered value is correct", func(t *testing.T) {
mi.ObserveClusterReliableFallbackLength(event, length)
actualMetric, err := miImpl.ClusterReliableFallbackLength.GetMetricWith(prometheus.Labels{"event": string(event)})
require.NoError(t, err)
require.NoError(t, actualMetric.(prometheus.Histogram).Write(m))
require.Equal(t, uint64(1), m.Histogram.GetSampleCount())
require.InDelta(t, float64(length), m.Histogram.GetSampleSum(), 0.001)
})
}
func TestMergeLabels(t *testing.T) {
t.Run("combines both maps", func(t *testing.T) {
got := mergeLabels(map[string]string{"a": "1"}, map[string]string{"b": "2"})
require.Equal(t, prometheus.Labels{"a": "1", "b": "2"}, got)
})
t.Run("extra takes precedence over base", func(t *testing.T) {
got := mergeLabels(map[string]string{"a": "1"}, map[string]string{"a": "2"})
require.Equal(t, prometheus.Labels{"a": "2"}, got)
})
t.Run("handles nil maps", func(t *testing.T) {
require.Equal(t, prometheus.Labels{}, mergeLabels(nil, nil))
})
}
func TestServerInfo(t *testing.T) {
th := api4.SetupEnterprise(t, app.StartMetrics)
configureMetrics(th)
mi := th.App.Metrics()
miImpl, ok := mi.(*MetricsInterfaceImpl)
require.True(t, ok, fmt.Sprintf("App.Metrics is not *MetricsInterfaceImpl, but %T", mi))
m := &prometheusModels.Metric{}
require.NoError(t, miImpl.ServerInfo.Write(m))
t.Run("gauge value is always 1", func(t *testing.T) {
require.Equal(t, 1.0, m.Gauge.GetValue())
})
t.Run("carries the build information as labels", func(t *testing.T) {
labels := map[string]string{}
for _, pair := range m.GetLabel() {
labels[pair.GetName()] = pair.GetValue()
}
for key, want := range map[string]string{
"version": model.CurrentVersion,
"build_number": model.BuildNumber,
"build_hash": model.BuildHash,
"build_hash_enterprise": model.BuildHashEnterprise,
} {
require.Contains(t, labels, key)
require.Equal(t, want, labels[key])
}
})
}

View File

@@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
// Ensure this is a valid package even when build tags preclude building anything in it.
package enterprise