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:
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user