From 2d2d522f8662d4251f711bf6f8464ead7cee2179 Mon Sep 17 00:00:00 2001 From: Jesse Hallam Date: Thu, 21 May 2026 19:23:04 -0300 Subject: [PATCH] MM-68248: Handle missing indexes gracefully before reindex OpenSearch v3 rejects _update_by_query and _delete_by_query with no index argument (405), and returns index_not_found_exception (404) when querying an exact index name that hasn't been created yet. Both arise before any reindex has run, since indexes are created on first document write. Return nil/empty instead of an error from all affected operations, and add test coverage for each in the no-indexes state. --- .../elasticsearch/opensearch/opensearch.go | 33 ++++++++++++ .../opensearch/opensearch_test.go | 53 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/server/enterprise/elasticsearch/opensearch/opensearch.go b/server/enterprise/elasticsearch/opensearch/opensearch.go index cad70b036c9..8cb4ed7554f 100644 --- a/server/enterprise/elasticsearch/opensearch/opensearch.go +++ b/server/enterprise/elasticsearch/opensearch/opensearch.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "slices" @@ -40,6 +41,14 @@ var ( purgeIndexListAllowedIndexes = []string{common.IndexBaseChannels} ) +// isIndexNotFound reports whether err is a 404 index_not_found_exception from +// OpenSearch. This happens when an index has never been created (e.g. no +// reindex has run yet) and should be treated as an empty result, not an error. +func isIndexNotFound(err error) bool { + var osErr *opensearch.StructError + return errors.As(err, &osErr) && osErr.Status == http.StatusNotFound +} + type OpensearchInterfaceImpl struct { client *opensearchapi.Client mutex sync.RWMutex @@ -840,6 +849,9 @@ func (os *OpensearchInterfaceImpl) DeleteChannelPosts(rctx request.CTX, channelI if err != nil { return model.NewAppError("Opensearch.DeleteChannelPosts", "ent.elasticsearch.delete_channel_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } + if len(postIndexes) == 0 { + return nil + } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() @@ -881,6 +893,9 @@ func (os *OpensearchInterfaceImpl) UpdatePostsChannelTypeByChannelId(rctx reques if err != nil { return model.NewAppError("Opensearch.UpdatePostsChannelTypeByChannelId", "ent.elasticsearch.update_posts_channel_type.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } + if len(postIndexes) == 0 { + return nil + } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() @@ -943,6 +958,9 @@ func (os *OpensearchInterfaceImpl) BackfillPostsChannelType(rctx request.CTX, ch if err != nil { return model.NewAppError("Opensearch.BackfillPostsChannelType", "ent.elasticsearch.backfill_posts_channel_type.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } + if len(postIndexes) == 0 { + return nil + } ctx, cancel := context.WithTimeout(context.Background(), time.Hour) defer cancel() @@ -1014,6 +1032,9 @@ func (os *OpensearchInterfaceImpl) DeleteUserPosts(rctx request.CTX, userID stri if err != nil { return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.delete_user_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } + if len(postIndexes) == 0 { + return nil + } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() @@ -2127,6 +2148,9 @@ func (os *OpensearchInterfaceImpl) SearchFiles(channels model.ChannelList, searc }, }) if err != nil { + if isIndexNotFound(err) { + return []string{}, nil + } errorStr := "err=" + err.Error() if *os.Platform.Config().ElasticsearchSettings.Trace == "error" { errorStr = "Query=" + getJSONOrErrorStr(query) + ", " + errorStr @@ -2210,6 +2234,9 @@ func (os *OpensearchInterfaceImpl) DeleteUserFiles(rctx request.CTX, userID stri Body: bytes.NewReader(queryBuf), }) if err != nil { + if isIndexNotFound(err) { + return nil + } return model.NewAppError("Opensearch.DeleteUserFiles", "ent.elasticsearch.delete_user_files.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("User files deleted", mlog.String("user_id", userID), mlog.Int("deleted", response.Deleted)) @@ -2246,6 +2273,9 @@ func (os *OpensearchInterfaceImpl) DeletePostFiles(rctx request.CTX, postID stri Body: bytes.NewReader(queryBuf), }) if err != nil { + if isIndexNotFound(err) { + return nil + } return model.NewAppError("Opensearch.DeletePostFiles", "ent.elasticsearch.delete_post_files.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("Post files deleted", mlog.String("post_id", postID), mlog.Int("deleted", response.Deleted)) @@ -2293,6 +2323,9 @@ func (os *OpensearchInterfaceImpl) DeleteFilesBatch(rctx request.CTX, endTime, l }, }) if err != nil { + if isIndexNotFound(err) { + return nil + } return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.delete_user_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("Files batch deleted", mlog.Int("end_time", endTime), mlog.Int("limit", limit), mlog.Int("deleted", response.Deleted)) diff --git a/server/enterprise/elasticsearch/opensearch/opensearch_test.go b/server/enterprise/elasticsearch/opensearch/opensearch_test.go index ca5bebd059e..f9e0f5c045d 100644 --- a/server/enterprise/elasticsearch/opensearch/opensearch_test.go +++ b/server/enterprise/elasticsearch/opensearch/opensearch_test.go @@ -212,6 +212,59 @@ func (s *OpensearchInterfaceTestSuite) TestSyncBulkIndexChannels() { }) } +// 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