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
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:
39
server/enterprise/LICENSE
Normal file
39
server/enterprise/LICENSE
Normal 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.
|
||||
17
server/enterprise/README.md
Normal file
17
server/enterprise/README.md
Normal 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).
|
||||
404
server/enterprise/elasticsearch/common/common.go
Normal file
404
server/enterprise/elasticsearch/common/common.go
Normal 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
|
||||
}
|
||||
663
server/enterprise/elasticsearch/common/common_test.go
Normal file
663
server/enterprise/elasticsearch/common/common_test.go
Normal 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)
|
||||
}
|
||||
906
server/enterprise/elasticsearch/common/indexing_job.go
Normal file
906
server/enterprise/elasticsearch/common/indexing_job.go
Normal 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
|
||||
}
|
||||
296
server/enterprise/elasticsearch/common/indexing_job_test.go
Normal file
296
server/enterprise/elasticsearch/common/indexing_job_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
89
server/enterprise/elasticsearch/common/logger.go
Normal file
89
server/enterprise/elasticsearch/common/logger.go
Normal 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))
|
||||
}
|
||||
343
server/enterprise/elasticsearch/common/templates.go
Normal file
343
server/enterprise/elasticsearch/common/templates.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
242
server/enterprise/elasticsearch/common/test_helpers.go
Normal file
242
server/enterprise/elasticsearch/common/test_helpers.go
Normal 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]))
|
||||
}
|
||||
}
|
||||
1525
server/enterprise/elasticsearch/common/test_suite.go
Normal file
1525
server/enterprise/elasticsearch/common/test_suite.go
Normal file
File diff suppressed because it is too large
Load Diff
27
server/enterprise/elasticsearch/common/version.go
Normal file
27
server/enterprise/elasticsearch/common/version.go
Normal 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
|
||||
}
|
||||
51
server/enterprise/elasticsearch/common/version_test.go
Normal file
51
server/enterprise/elasticsearch/common/version_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
329
server/enterprise/elasticsearch/elasticsearch/aggregation_job.go
Normal file
329
server/enterprise/elasticsearch/elasticsearch/aggregation_job.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
61
server/enterprise/elasticsearch/elasticsearch/bulk.go
Normal file
61
server/enterprise/elasticsearch/elasticsearch/bulk.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
170
server/enterprise/elasticsearch/elasticsearch/bulk_client_req.go
Normal file
170
server/enterprise/elasticsearch/elasticsearch/bulk_client_req.go
Normal 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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
78
server/enterprise/elasticsearch/elasticsearch/bulk_test.go
Normal file
78
server/enterprise/elasticsearch/elasticsearch/bulk_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
134
server/enterprise/elasticsearch/elasticsearch/common.go
Normal file
134
server/enterprise/elasticsearch/elasticsearch/common.go
Normal 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
|
||||
}
|
||||
2251
server/enterprise/elasticsearch/elasticsearch/elasticsearch.go
Normal file
2251
server/enterprise/elasticsearch/elasticsearch/elasticsearch.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
112
server/enterprise/elasticsearch/elasticsearch/indexing_job.go
Normal file
112
server/enterprise/elasticsearch/elasticsearch/indexing_job.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
21
server/enterprise/elasticsearch/elasticsearch/main_test.go
Normal file
21
server/enterprise/elasticsearch/elasticsearch/main_test.go
Normal 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)
|
||||
}
|
||||
94
server/enterprise/elasticsearch/elasticsearch/sync_bulk.go
Normal file
94
server/enterprise/elasticsearch/elasticsearch/sync_bulk.go
Normal 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
|
||||
}
|
||||
28
server/enterprise/elasticsearch/elasticsearch/testlib.go
Normal file
28
server/enterprise/elasticsearch/elasticsearch/testlib.go
Normal 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
|
||||
}
|
||||
36
server/enterprise/elasticsearch/init.go
Normal file
36
server/enterprise/elasticsearch/init.go
Normal 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}
|
||||
})
|
||||
}
|
||||
330
server/enterprise/elasticsearch/opensearch/aggregation_job.go
Normal file
330
server/enterprise/elasticsearch/opensearch/aggregation_job.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
197
server/enterprise/elasticsearch/opensearch/bulk.go
Normal file
197
server/enterprise/elasticsearch/opensearch/bulk.go
Normal 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()
|
||||
}
|
||||
587
server/enterprise/elasticsearch/opensearch/bulk_test.go
Normal file
587
server/enterprise/elasticsearch/opensearch/bulk_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
131
server/enterprise/elasticsearch/opensearch/check_version_test.go
Normal file
131
server/enterprise/elasticsearch/opensearch/check_version_test.go
Normal 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)
|
||||
}
|
||||
124
server/enterprise/elasticsearch/opensearch/common.go
Normal file
124
server/enterprise/elasticsearch/opensearch/common.go
Normal 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
|
||||
}
|
||||
114
server/enterprise/elasticsearch/opensearch/indexing_job.go
Normal file
114
server/enterprise/elasticsearch/opensearch/indexing_job.go
Normal 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
|
||||
})
|
||||
}
|
||||
246
server/enterprise/elasticsearch/opensearch/indexing_job_test.go
Normal file
246
server/enterprise/elasticsearch/opensearch/indexing_job_test.go
Normal 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)
|
||||
}
|
||||
21
server/enterprise/elasticsearch/opensearch/main_test.go
Normal file
21
server/enterprise/elasticsearch/opensearch/main_test.go
Normal 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)
|
||||
}
|
||||
2368
server/enterprise/elasticsearch/opensearch/opensearch.go
Normal file
2368
server/enterprise/elasticsearch/opensearch/opensearch.go
Normal file
File diff suppressed because it is too large
Load Diff
651
server/enterprise/elasticsearch/opensearch/opensearch_test.go
Normal file
651
server/enterprise/elasticsearch/opensearch/opensearch_test.go
Normal 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",
|
||||
})
|
||||
})
|
||||
}
|
||||
28
server/enterprise/elasticsearch/opensearch/testlib.go
Normal file
28
server/enterprise/elasticsearch/opensearch/testlib.go
Normal 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
|
||||
}
|
||||
53
server/enterprise/external_imports.go
Normal file
53
server/enterprise/external_imports.go
Normal 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"
|
||||
)
|
||||
13
server/enterprise/local_imports.go
Normal file
13
server/enterprise/local_imports.go
Normal 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"
|
||||
)
|
||||
544
server/enterprise/message_export/shared/export_data.go
Normal file
544
server/enterprise/message_export/shared/export_data.go
Normal 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,
|
||||
}
|
||||
}
|
||||
522
server/enterprise/message_export/shared/export_data_test.go
Normal file
522
server/enterprise/message_export/shared/export_data_test.go
Normal 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])
|
||||
}
|
||||
21
server/enterprise/message_export/shared/main_test.go
Normal file
21
server/enterprise/message_export/shared/main_test.go
Normal 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)
|
||||
}
|
||||
613
server/enterprise/message_export/shared/shared.go
Normal file
613
server/enterprise/message_export/shared/shared.go
Normal 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
|
||||
}
|
||||
286
server/enterprise/message_export/shared/shared_test.go
Normal file
286
server/enterprise/message_export/shared/shared_test.go
Normal 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)
|
||||
}
|
||||
36
server/enterprise/message_export/shared/store.go
Normal file
36
server/enterprise/message_export/shared/store.go
Normal 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()
|
||||
}
|
||||
81
server/enterprise/metrics/dynamic.go
Normal file
81
server/enterprise/metrics/dynamic.go
Normal 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)
|
||||
}
|
||||
74
server/enterprise/metrics/histogram.go
Normal file
74
server/enterprise/metrics/histogram.go
Normal 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)
|
||||
}
|
||||
34
server/enterprise/metrics/histogram_test.go
Normal file
34
server/enterprise/metrics/histogram_test.go
Normal 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.")
|
||||
}
|
||||
21
server/enterprise/metrics/main_test.go
Normal file
21
server/enterprise/metrics/main_test.go
Normal 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)
|
||||
}
|
||||
2439
server/enterprise/metrics/metrics.go
Normal file
2439
server/enterprise/metrics/metrics.go
Normal file
File diff suppressed because it is too large
Load Diff
401
server/enterprise/metrics/metrics_test.go
Normal file
401
server/enterprise/metrics/metrics_test.go
Normal 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])
|
||||
}
|
||||
})
|
||||
}
|
||||
5
server/enterprise/placeholder.go
Normal file
5
server/enterprise/placeholder.go
Normal 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
|
||||
Reference in New Issue
Block a user