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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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