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.
This commit is contained in:
Jesse Hallam
2026-05-21 19:40:34 -03:00
parent 15109095f4
commit 2d2d522f86
2 changed files with 86 additions and 0 deletions
@@ -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))
@@ -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