Move Elasticsearch to source available 🎉 🎉 (#29015)

* Move Elasticsearch to source available
Этот коммит содержится в:
Agniva De Sarker
2024-11-06 09:26:54 +05:30
коммит произвёл GitHub
родитель 311381940d
Коммит 65ed87bda0
38 изменённых файлов: 9267 добавлений и 5 удалений

Просмотреть файл

@@ -0,0 +1,331 @@
// 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)
claimed, appErr := worker.jobServer.ClaimJob(job)
if appErr != nil {
logger.Warn("Worker: Error occurred while trying to claim job", mlog.Err(appErr))
return
}
if !claimed {
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", nil, "", http.StatusInternalServerError).Wrap(err)
worker.setJobError(logger, job, appError)
return
}
for index := range postIndexesResult.Indices {
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", nil, "", 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", nil, "", 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", nil, "", http.StatusInternalServerError)
logger.Error("Worker: Index aggregation job failed", mlog.Err(appError))
worker.setJobError(logger, job, appError)
return
}
}
}
}
func (worker *OpensearchAggregatorWorker) setJobSuccess(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobSuccess(job); err != nil {
logger.Error("Worker: Failed to set success for job", mlog.Err(err))
worker.setJobError(logger, job, err)
}
}
func (worker *OpensearchAggregatorWorker) setJobError(logger mlog.LoggerIFace, job *model.Job, appError *model.AppError) {
if err := worker.jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err))
}
}
func (worker *OpensearchAggregatorWorker) setJobCanceled(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobCanceled(job); err != nil {
logger.Error("Worker: Failed to mark job as canceled", mlog.Err(err))
}
}

Просмотреть файл

@@ -0,0 +1,223 @@
// 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(65535, 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(true, 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()
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 := 0; i < indexDeletionBatchSize+1; i++ {
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(65535, 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")
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(*esImpl.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second)
defer cancel()
buf, err := json.Marshal(searchPost)
require.NoError(t, err)
_, err = esImpl.client.Index(ctx, opensearchapi.IndexReq{
Index: indexName,
DocumentID: post.Id,
Body: bytes.NewReader(buf),
})
require.NoError(t, err)
}

Просмотреть файл

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

Просмотреть файл

@@ -0,0 +1,162 @@
// 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/model"
"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
logger mlog.LoggerIFace
client *opensearchapi.Client
settings model.ElasticsearchSettings
quitFlusher chan struct{}
quitFlusherWg sync.WaitGroup
pendingRequests int
}
func NewBulk(settings model.ElasticsearchSettings,
logger mlog.LoggerIFace,
client *opensearchapi.Client) *Bulk {
b := &Bulk{
settings: settings,
logger: logger,
client: client,
quitFlusher: make(chan struct{}),
buf: &bytes.Buffer{},
}
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 {
r.pendingRequests++
if r.pendingRequests > *r.settings.LiveIndexingBatchSize {
return r._flush()
}
return nil
}
func (r *Bulk) Stop() error {
r.mut.Lock()
defer r.mut.Unlock()
r.logger.Info("Stopping Bulk processor")
if r.pendingRequests > 0 {
return r._flush()
}
close(r.quitFlusher)
r.quitFlusherWg.Wait()
return nil
}
func (r *Bulk) periodicFlusher() {
defer r.quitFlusherWg.Done()
for {
select {
case <-time.After(common.BulkFlushInterval):
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 {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*r.settings.RequestTimeoutSeconds)*time.Second)
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
}

Просмотреть файл

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"os"
"testing"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/stretchr/testify/require"
)
func TestBulkProcessor(t *testing.T) {
th := api4.SetupEnterprise(t)
defer th.TearDown()
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.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(th.App.Config().ElasticsearchSettings,
th.Server.Platform().Log(),
client)
post, err := common.ESPostFromPost(&model.Post{
Id: model.NewId(),
Message: "hello world",
}, "myteam")
require.NoError(t, err)
err = bulk.IndexOp(&types.IndexOperation{
Index_: model.NewPointer("myindex"),
Id_: model.NewPointer(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)
}

Просмотреть файл

@@ -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("Elasticsearch.createClient", "ent.elasticsearch.create_client.connect_failed", nil, "", 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 {
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("Elasticsearch.createClient", "ent.elasticsearch.create_client.ca_cert_missing", nil, "", http.StatusInternalServerError).Wrap(err)
}
esCfg.CACert = clientCA
return nil
}
func configureClientCertificate(tlsConfig *tls.Config, cfg *model.Config, fb filestore.FileBackend) *model.AppError {
// read the client certificate file
clientCert, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.ClientCert)
if err != nil {
return model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.client_cert_missing", nil, "", http.StatusInternalServerError).Wrap(err)
}
// read the client key file
clientKey, err := common.ReadFileSafely(fb, *cfg.ElasticsearchSettings.ClientKey)
if err != nil {
return model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.client_key_missing", nil, "", http.StatusInternalServerError).Wrap(err)
}
// load the client key and certificate
certificate, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
return model.NewAppError("Elasticsearch.createClient", "ent.elasticsearch.create_client.client_cert_malformed", nil, "", http.StatusInternalServerError).Wrap(err)
}
// update the TLS config
tlsConfig.Certificates = []tls.Certificate{certificate}
return nil
}

Просмотреть файл

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"context"
"io"
"time"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"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
}
return common.NewIndexerWorker(workerName,
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,
})
},
// Closing the bulk processor
func() error {
return esi.bulkProcessor.Close(context.Background())
})
}

Просмотреть файл

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"testing"
"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)
defer th.TearDown()
th.Server.SetLicense(model.NewTestLicense("elastic_search"))
osImpl := &OpensearchIndexerInterfaceImpl{
Server: th.Server,
}
worker := osImpl.MakeWorker()
config := &model.Config{
ElasticsearchSettings: model.ElasticsearchSettings{
EnableIndexing: model.NewPointer(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)
defer th.TearDown()
th.Server.SetLicense(nil)
osImpl := &OpensearchIndexerInterfaceImpl{
Server: th.Server,
}
worker := osImpl.MakeWorker()
config := &model.Config{
ElasticsearchSettings: model.ElasticsearchSettings{
EnableIndexing: model.NewPointer(true),
},
}
result := worker.IsEnabled(config)
assert.Equal(t, result, false)
})
}
func TestOpenSearchIndexerPending(t *testing.T) {
th := api4.SetupEnterprise(t).InitBasic()
defer th.TearDown()
// 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)
}

Просмотреть файл

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

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/suite"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common"
"github.com/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 (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.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.Indices {
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() {
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())
s.Nil(s.CommonTestSuite.ESImpl.PurgeIndexes(s.th.Context))
}

Просмотреть файл

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