Files
upstream-mattermost/server/channels/app/platform/searchengine_watcher.go
upstream-mirror 240db4e2a7
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
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

390 lines
12 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"context"
"sync"
"sync/atomic"
"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/platform/services/searchengine"
)
// Watcher tuning knobs -- declared as vars so tests can override them.
var (
searchEngineRetryInitial = 15 * time.Second
searchEngineRetryMax = 5 * time.Minute
searchEngineHealthInterval = 60 * time.Second
searchEngineHealthFailThreshold = 3
searchEngineStopTimeout = 15 * time.Second
)
// searchEngineWatcher monitors an Elasticsearch engine's health and manages
// its lifecycle (Start / Stop) in a single background goroutine. All engine
// state mutations happen inside this goroutine, eliminating races between
// concurrent callers.
//
// External code communicates with the watcher through two signals:
// - reevaluate(): wake the watcher for immediate re-evaluation
// - requestRestart(): tell the watcher to Stop() before re-evaluating
type searchEngineWatcher struct {
ps *PlatformService
engine searchengine.SearchEngineInterface
// Coordination -- accessed from multiple goroutines.
mu sync.Mutex
cancel context.CancelFunc // nil when not running
done chan struct{} // closed when goroutine exits
notifyCh chan struct{} // buffered(1), non-blocking wake signal
forceRestart int32 // atomic: 1 = stop engine before next evaluation
}
// watcherLoopState holds per-goroutine loop state. Declared locally in run()
// and passed by pointer to helpers, so an abandoned goroutine and a fresh one
// never share these fields.
type watcherLoopState struct {
backoff time.Duration
consecutiveFailures int
}
func newSearchEngineWatcher(ps *PlatformService) *searchEngineWatcher {
return &searchEngineWatcher{
ps: ps,
engine: ps.SearchEngine.ElasticsearchEngine,
}
}
// start launches the watcher goroutine. Idempotent: no-op if already running.
func (w *searchEngineWatcher) start() {
w.mu.Lock()
defer w.mu.Unlock()
if w.cancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.done = make(chan struct{})
w.notifyCh = make(chan struct{}, 1)
done := w.done
notifyCh := w.notifyCh
go func() {
defer close(done)
w.run(ctx, notifyCh)
}()
}
// stop cancels the watcher and waits for it to exit with a bounded timeout.
// If the watcher is stuck (e.g. in a blocking Start() call), the goroutine is
// abandoned and will self-terminate when the blocking call returns. The mutex
// is NOT held during the wait.
func (w *searchEngineWatcher) stop() {
w.mu.Lock()
cancel := w.cancel
done := w.done
w.cancel = nil
w.done = nil
w.notifyCh = nil
w.mu.Unlock()
if cancel == nil {
return
}
cancel()
select {
case <-done:
case <-time.After(searchEngineStopTimeout):
w.ps.Log().Warn("Search engine watcher did not stop in time; abandoning goroutine",
mlog.Duration("timeout", searchEngineStopTimeout))
}
}
// reevaluate sends a non-blocking signal to the watcher to re-evaluate engine
// state immediately. Safe to call on a nil receiver.
func (w *searchEngineWatcher) reevaluate() {
if w == nil {
return
}
w.mu.Lock()
ch := w.notifyCh
w.mu.Unlock()
if ch != nil {
select {
case ch <- struct{}{}:
default:
}
}
}
// requestRestart sets a flag that tells the watcher to Stop() the engine
// before its next evaluation cycle, then wakes it for immediate re-evaluation.
// Used when connection settings change or the license is removed.
// Safe to call on a nil receiver.
func (w *searchEngineWatcher) requestRestart() {
if w == nil {
return
}
atomic.StoreInt32(&w.forceRestart, 1)
w.reevaluate()
}
// run is the watcher's main loop. It drives a simple state machine:
//
// (start)
// |
// v
// forceRestart? --yes--> Stop()
// |
// v
// IsEnabled()? --no---> PARK (wait for notify or cancel)
// |
// yes
// |
// v
// IsActive()? --yes---> HEALTH CHECK (periodic)
// | |
// no N consecutive failures
// | |
// v Stop() engine
// RETRY <---------------------+
// |
// | wait (exponential backoff, interruptible)
// v
// Start()
// |--error--> backoff*2 (capped), retry
// |--ok-----> reset backoff, HEALTH CHECK
//
// Any state -- ctx.Done() --> EXIT
// Any state -- reevaluate --> immediate re-evaluation
func (w *searchEngineWatcher) run(ctx context.Context, notifyCh <-chan struct{}) {
if w.engine == nil {
return
}
// Safety net: if the engine is still active when the goroutine exits
// (e.g. Start() completed just as the context was canceled), stop it so
// it is not left running with no goroutine to manage it.
defer func() {
if w.engine.IsActive() {
w.ps.Log().Info("Search engine watcher: stopping engine on goroutine exit",
mlog.String("engine", w.engine.GetName()))
if err := w.engine.Stop(); err != nil {
w.ps.Log().Warn("Search engine watcher: Stop() failed on goroutine exit",
mlog.Err(err),
mlog.String("engine", w.engine.GetName()))
}
}
}()
s := &watcherLoopState{backoff: searchEngineRetryInitial}
rctx := request.EmptyContext(w.ps.logger)
timer := time.NewTimer(0) // immediate first evaluation
defer timer.Stop()
for {
if !w.waitForEvent(ctx, timer, notifyCh, s) {
return
}
w.applyForceRestart()
if w.parkIfDisabled(timer) {
continue
}
if w.startIfInactive(ctx, timer, s) {
continue
}
w.healthCheck(rctx, timer, s)
}
}
// waitForEvent blocks until the timer fires, a notify arrives, or the context
// is cancelled. Returns false when the watcher should exit.
func (w *searchEngineWatcher) waitForEvent(ctx context.Context, timer *time.Timer, notifyCh <-chan struct{}, s *watcherLoopState) bool {
for {
select {
case <-ctx.Done():
return false
case <-notifyCh:
// Something changed -- re-evaluate immediately.
timer.Reset(0)
s.backoff = searchEngineRetryInitial
s.consecutiveFailures = 0
continue
case <-timer.C:
}
// Prioritize shutdown: if select picked timer.C and ctx.Done()
// simultaneously (random when both are ready), exit now.
return ctx.Err() == nil
}
}
// applyForceRestart checks and clears the force-restart flag. If set and the
// engine is active, it stops the engine so the next evaluation cycle will
// create a fresh client.
func (w *searchEngineWatcher) applyForceRestart() {
if !atomic.CompareAndSwapInt32(&w.forceRestart, 1, 0) {
return
}
if w.engine.IsActive() {
w.ps.Log().Info("Search engine watcher: force-restart requested, stopping engine",
mlog.String("engine", w.engine.GetName()))
if err := w.engine.Stop(); err != nil {
w.ps.Log().Warn("Search engine watcher: Stop() failed during force-restart",
mlog.Err(err),
mlog.String("engine", w.engine.GetName()))
}
}
}
// parkIfDisabled stops the engine (if active) and parks the watcher when the
// engine is disabled. Returns true when parked (caller should continue the
// loop without scheduling a timer -- the next wake comes from reevaluate).
func (w *searchEngineWatcher) parkIfDisabled(timer *time.Timer) bool {
if w.engine.IsEnabled() {
return false
}
if w.engine.IsActive() {
if err := w.engine.Stop(); err != nil {
w.ps.Log().Warn("Search engine watcher: Stop() returned error while disabling",
mlog.Err(err),
mlog.String("engine", w.engine.GetName()))
}
}
w.ps.Log().Info("Search engine watcher: engine disabled, parking")
timer.Stop()
return true
}
// parkIfUnlicensed parks the watcher when the server has no Elasticsearch
// license. Returns true when parked (caller should continue the loop).
func (w *searchEngineWatcher) parkIfUnlicensed(timer *time.Timer) bool {
license := w.ps.License()
if license != nil && model.SafeDereference(license.Features.Elasticsearch) {
return false
}
w.ps.Log().Info("Search engine watcher: engine not active (no Elasticsearch license), parking",
mlog.String("engine", w.engine.GetName()))
timer.Stop()
return true
}
// startIfInactive attempts to start the engine when it is not active.
// Returns true when it handled the state (caller should continue the loop).
func (w *searchEngineWatcher) startIfInactive(ctx context.Context, timer *time.Timer, s *watcherLoopState) bool {
if w.engine.IsActive() {
return false
}
if err := w.engine.Start(ctx); err != nil {
s.consecutiveFailures++
w.ps.Log().Error("Search engine watcher: Start() failed, will retry",
mlog.Err(err),
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.Duration("next_backoff", s.backoff),
mlog.String("engine", w.engine.GetName()))
timer.Reset(s.backoff)
s.backoff = min(s.backoff*2, searchEngineRetryMax)
return true
}
if ctx.Err() != nil {
return true // shutting down
}
// Start() returned nil but engine may not be active (e.g. no license).
if !w.engine.IsActive() {
if w.parkIfUnlicensed(timer) {
return true
}
s.consecutiveFailures++
w.ps.Log().Warn("Search engine watcher: Start() returned no error but engine is not active, will retry",
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.Duration("next_backoff", s.backoff),
mlog.String("engine", w.engine.GetName()))
timer.Reset(s.backoff)
s.backoff = min(s.backoff*2, searchEngineRetryMax)
return true
}
w.ps.Log().Info("Search engine watcher: engine started successfully",
mlog.String("engine", w.engine.GetName()))
s.backoff = searchEngineRetryInitial
s.consecutiveFailures = 0
if model.SafeDereference(w.ps.Config().ElasticsearchSettings.EnableSearchPublicChannelsWithoutMembership) {
engine := w.engine
w.ps.Go(func() {
w.ps.backfillPostsChannelType(engine)
})
}
timer.Reset(searchEngineHealthInterval)
return true
}
// healthCheck runs a periodic health check on an active engine. After
// reaching the consecutive failure threshold it stops the engine and
// schedules a retry; otherwise it schedules the next health check.
func (w *searchEngineWatcher) healthCheck(rctx request.CTX, timer *time.Timer, s *watcherLoopState) {
// Save the previous state so that we can log any state change
wasHealthy := w.engine.IsHealthy()
if err := w.engine.HealthCheck(rctx); err != nil {
// Mark unhealthy on the first failure so the broker skips this
// engine immediately (searches fall back to DB, indexing is
// deferred). The engine stays started until the consecutive
// failure threshold is reached.
w.engine.SetHealthy(false)
if wasHealthy {
w.ps.Log().Warn("Search engine health check failed: it is now marked as unhealthy",
mlog.String("engine", w.engine.GetName()))
}
s.consecutiveFailures++
if s.consecutiveFailures >= searchEngineHealthFailThreshold {
w.ps.Log().Error("Search engine health check failed repeatedly; stopping engine",
mlog.Err(err),
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.String("engine", w.engine.GetName()))
if stopErr := w.engine.Stop(); stopErr != nil {
w.ps.Log().Warn("Search engine watcher: Stop() returned error (may already be stopped)",
mlog.Err(stopErr),
mlog.String("engine", w.engine.GetName()))
}
s.consecutiveFailures = 0
s.backoff = searchEngineRetryInitial
timer.Reset(s.backoff)
return
}
w.ps.Log().Warn("Search engine health check failed",
mlog.Err(err),
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.Int("threshold", searchEngineHealthFailThreshold),
mlog.String("engine", w.engine.GetName()))
} else {
w.engine.SetHealthy(true)
if !wasHealthy {
w.ps.Log().Info("Search engine health check succeeded: it is now marked as healthy",
mlog.String("engine", w.engine.GetName()))
}
s.consecutiveFailures = 0
}
timer.Reset(searchEngineHealthInterval)
}