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 Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 1s
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 Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 1s
This commit is contained in:
735
server/channels/app/platform/searchengine_test.go
Normal file
735
server/channels/app/platform/searchengine_test.go
Normal file
@@ -0,0 +1,735 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/testlib"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
|
||||
searchenginemocks "github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
|
||||
)
|
||||
|
||||
// overrideWatcherTimings sets all watcher durations to fast values for testing
|
||||
// and restores the originals when the test finishes.
|
||||
func overrideWatcherTimings(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
origRetryInitial := searchEngineRetryInitial
|
||||
origRetryMax := searchEngineRetryMax
|
||||
origHealthInterval := searchEngineHealthInterval
|
||||
origHealthFailThreshold := searchEngineHealthFailThreshold
|
||||
origStopTimeout := searchEngineStopTimeout
|
||||
|
||||
searchEngineRetryInitial = 10 * time.Millisecond
|
||||
searchEngineRetryMax = 50 * time.Millisecond
|
||||
searchEngineHealthInterval = 10 * time.Millisecond
|
||||
searchEngineHealthFailThreshold = 3
|
||||
searchEngineStopTimeout = 500 * time.Millisecond
|
||||
|
||||
t.Cleanup(func() {
|
||||
searchEngineRetryInitial = origRetryInitial
|
||||
searchEngineRetryMax = origRetryMax
|
||||
searchEngineHealthInterval = origHealthInterval
|
||||
searchEngineHealthFailThreshold = origHealthFailThreshold
|
||||
searchEngineStopTimeout = origStopTimeout
|
||||
})
|
||||
}
|
||||
|
||||
// setupWatcherTest creates a PlatformService with a mock search engine wired
|
||||
// into the broker and a ready-to-use watcher. Only GetName is pre-configured
|
||||
// on the mock — each test sets up other expectations explicitly.
|
||||
func setupWatcherTest(t *testing.T) (*searchEngineWatcher, *searchenginemocks.SearchEngineInterface) {
|
||||
t.Helper()
|
||||
overrideWatcherTimings(t)
|
||||
|
||||
th := SetupWithStoreMock(t)
|
||||
ps := th.Service
|
||||
|
||||
engineMock := &searchenginemocks.SearchEngineInterface{}
|
||||
engineMock.On("GetName").Return("test-engine").Maybe()
|
||||
engineMock.On("IsHealthy").Return(true).Maybe()
|
||||
engineMock.On("SetHealthy", mock.Anything).Maybe()
|
||||
|
||||
// Make backfillPostsChannelType return immediately by indicating the backfill
|
||||
// is already done. This prevents the goroutine from calling unmocked store
|
||||
// methods in tests that only exercise watcher retry/health logic.
|
||||
systemMock := &storemocks.SystemStore{}
|
||||
systemMock.On("GetByName", model.SystemPostChannelTypeBackfillComplete).
|
||||
Return(&model.System{Name: model.SystemPostChannelTypeBackfillComplete, Value: "true"}, nil).Maybe()
|
||||
ps.Store.(*storemocks.Store).On("System").Return(systemMock).Maybe()
|
||||
|
||||
ps.SearchEngine = searchengine.NewBroker(ps.Config())
|
||||
ps.SearchEngine.ElasticsearchEngine = engineMock
|
||||
|
||||
w := newSearchEngineWatcher(ps)
|
||||
ps.esWatcher = w
|
||||
|
||||
return w, engineMock
|
||||
}
|
||||
|
||||
func TestRunSearchEngineWatcher(t *testing.T) {
|
||||
t.Run("retries Start on failure then transitions to health phase", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
var logBuffer mlog.Buffer
|
||||
require.NoError(t, mlog.AddWriterTarget(w.ps.Logger(), &logBuffer, true, mlog.StdAll...))
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
|
||||
// Start() fails twice, then succeeds on the third call.
|
||||
var startCalls atomic.Int32
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
n := startCalls.Add(1)
|
||||
if n <= 2 {
|
||||
return model.NewAppError("test", "start_failed", nil, "", 500)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// After Start() succeeds, IsActive returns true so the watcher
|
||||
// transitions to the health phase.
|
||||
engineMock.On("IsActive").Return(func() bool {
|
||||
return startCalls.Load() > 2
|
||||
})
|
||||
|
||||
// Track whether HealthCheck is called — that confirms the
|
||||
// watcher entered the health phase.
|
||||
var healthChecked atomic.Int32
|
||||
engineMock.On("HealthCheck", mock.Anything).Run(func(mock.Arguments) {
|
||||
healthChecked.Store(1)
|
||||
}).Return(nil).Maybe()
|
||||
|
||||
// The engine will be active when the watcher exits, so the
|
||||
// cleanup defer in run() will call Stop().
|
||||
engineMock.On("Stop").Return(nil).Maybe()
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return healthChecked.Load() == 1
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"watcher should have transitioned to health phase after Start succeeds")
|
||||
|
||||
require.NoError(t, w.ps.Logger().Flush())
|
||||
logText := logBuffer.String()
|
||||
testlib.AssertLog(t, strings.NewReader(logText), mlog.LvlError.Name, "Search engine watcher: Start() failed, will retry")
|
||||
testlib.AssertNoLog(t, strings.NewReader(logText), mlog.LvlWarn.Name, "Search engine watcher: Start() failed, will retry")
|
||||
})
|
||||
|
||||
t.Run("exponential backoff with cap", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
// Start() always fails — we record timestamps to observe backoff.
|
||||
var (
|
||||
startTimes []time.Time
|
||||
startTimesMu sync.Mutex
|
||||
)
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
startTimesMu.Lock()
|
||||
startTimes = append(startTimes, time.Now())
|
||||
startTimesMu.Unlock()
|
||||
return model.NewAppError("test", "start_failed", nil, "", 500)
|
||||
})
|
||||
|
||||
w.start()
|
||||
|
||||
// Wait for several retries.
|
||||
require.Eventually(t, func() bool {
|
||||
startTimesMu.Lock()
|
||||
defer startTimesMu.Unlock()
|
||||
return len(startTimes) >= 5
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
w.stop()
|
||||
|
||||
// After stop returns, the watcher goroutine has exited, so
|
||||
// startTimes is safe to read without the lock.
|
||||
for i := 1; i < len(startTimes); i++ {
|
||||
gap := startTimes[i].Sub(startTimes[i-1])
|
||||
assert.LessOrEqual(t, gap.Milliseconds(), searchEngineRetryMax.Milliseconds()+50,
|
||||
"gap between retry %d and %d exceeds max: %v", i-1, i, gap)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exits when engine is disabled", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
// Engine starts enabled, then becomes disabled after first Start() attempt.
|
||||
var startCalls atomic.Int32
|
||||
engineMock.On("IsEnabled").Return(func() bool {
|
||||
return startCalls.Load() == 0
|
||||
})
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
startCalls.Add(1)
|
||||
return model.NewAppError("test", "start_failed", nil, "", 500)
|
||||
}).Maybe()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
notifyCh := make(chan struct{}, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w.run(ctx, notifyCh)
|
||||
}()
|
||||
|
||||
// The watcher should park because IsEnabled() returns false.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Cancel context to let the watcher exit.
|
||||
cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"watcher did not exit after context cancellation")
|
||||
})
|
||||
|
||||
t.Run("notify wakes watcher from park", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
// Start disabled, then enable after a notify.
|
||||
var enabled atomic.Int32
|
||||
engineMock.On("IsEnabled").Return(func() bool {
|
||||
return enabled.Load() == 1
|
||||
})
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
var started atomic.Int32
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
started.Store(1)
|
||||
return model.NewAppError("test", "start_failed", nil, "", 500)
|
||||
}).Maybe()
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
// Watcher should be parked (engine disabled).
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.Equal(t, int32(0), started.Load(), "Start should not be called when disabled")
|
||||
|
||||
// Enable and notify.
|
||||
enabled.Store(1)
|
||||
w.reevaluate()
|
||||
|
||||
// Watcher should wake up and try Start().
|
||||
require.Eventually(t, func() bool {
|
||||
return started.Load() == 1
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"watcher should have called Start after notify")
|
||||
})
|
||||
|
||||
t.Run("stops engine on exit when Start succeeds during shutdown", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
|
||||
// Start() succeeds but the context is cancelled while it runs,
|
||||
// simulating the race where Start() completes just as stop() is
|
||||
// called. Without the safety-net defer in run(), the engine
|
||||
// would be left active with no goroutine to manage it.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
notifyCh := make(chan struct{}, 1)
|
||||
|
||||
engineMock.On("IsActive").Return(false).Once() // first check in startIfInactive
|
||||
engineMock.On("Start", mock.Anything).Run(func(mock.Arguments) {
|
||||
cancel() // simulate stop() racing with Start()
|
||||
}).Return(nil).Once()
|
||||
|
||||
// After Start() returns nil, run() checks ctx.Err() != nil and
|
||||
// falls through to the defer, which sees the engine as active.
|
||||
engineMock.On("IsActive").Return(true).Once() // checked by the safety-net defer
|
||||
|
||||
var stopped atomic.Int32
|
||||
engineMock.On("Stop").Run(func(mock.Arguments) {
|
||||
stopped.Add(1)
|
||||
}).Return(nil).Once()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w.run(ctx, notifyCh)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"watcher did not exit after context cancellation")
|
||||
|
||||
assert.Equal(t, int32(1), stopped.Load(),
|
||||
"safety net should have called Stop() exactly once on goroutine exit")
|
||||
})
|
||||
}
|
||||
|
||||
func TestStartIfInactive(t *testing.T) {
|
||||
t.Run("parks when Start returns nil but no ES license", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
// Start() succeeds but engine stays inactive (no license).
|
||||
var startCalls atomic.Int32
|
||||
engineMock.On("Start", mock.Anything).Run(func(mock.Arguments) {
|
||||
startCalls.Add(1)
|
||||
}).Return(nil)
|
||||
|
||||
// No license is set — the default for the test PlatformService.
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
// Wait for the first Start() call.
|
||||
require.Eventually(t, func() bool {
|
||||
return startCalls.Load() >= 1
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
// The watcher should park — no further Start() calls after the first one.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
assert.Equal(t, int32(1), startCalls.Load(),
|
||||
"watcher should park after one Start() call when there is no ES license")
|
||||
engineMock.AssertNotCalled(t, "HealthCheck", mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("retries when Start returns nil but engine not active with ES license", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
var startCalls atomic.Int32
|
||||
engineMock.On("Start", mock.Anything).Run(func(mock.Arguments) {
|
||||
startCalls.Add(1)
|
||||
}).Return(nil)
|
||||
|
||||
// Set a license with Elasticsearch feature enabled.
|
||||
w.ps.licenseValue.Store(model.NewTestLicense("elastic_search"))
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
// The watcher should keep retrying because the license is present.
|
||||
require.Eventually(t, func() bool {
|
||||
return startCalls.Load() >= 3
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"watcher should retry Start when it returns nil but engine is not active (with license)")
|
||||
engineMock.AssertNotCalled(t, "HealthCheck", mock.Anything)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWatcherHealthPhase(t *testing.T) {
|
||||
t.Run("intermittent failures below threshold do not trigger Stop", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
// Engine starts active.
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(true).Maybe()
|
||||
|
||||
// The engine will be active when the watcher exits, so the
|
||||
// cleanup defer in run() will call Stop().
|
||||
engineMock.On("Stop").Return(nil).Maybe()
|
||||
|
||||
// HealthCheck alternates: fail, ok, fail, ok...
|
||||
// The consecutive failure counter resets on each success, so
|
||||
// the threshold is never reached and Stop() is never called
|
||||
// by the health-check logic (only by the exit defer).
|
||||
var healthCalls atomic.Int32
|
||||
engineMock.On("HealthCheck", mock.Anything).Return(func(_ request.CTX) *model.AppError {
|
||||
n := healthCalls.Add(1)
|
||||
if n%2 == 1 { // odd calls fail
|
||||
return model.NewAppError("test", "hc_fail", nil, "", 502)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return healthCalls.Load() >= 4
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
// Stop should not have been called by health-check logic —
|
||||
// failures never reached the threshold. (It will be called
|
||||
// once later by the cleanup defer in run().)
|
||||
engineMock.AssertNotCalled(t, "Stop")
|
||||
})
|
||||
|
||||
t.Run("consecutive failures trigger Stop and retry", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
|
||||
// Engine starts active; becomes inactive after Stop().
|
||||
var stopped atomic.Int32
|
||||
engineMock.On("IsActive").Return(func() bool {
|
||||
return stopped.Load() == 0
|
||||
})
|
||||
|
||||
// HealthCheck always fails — triggers Stop after 3 consecutive failures.
|
||||
engineMock.On("HealthCheck", mock.Anything).Return(
|
||||
model.NewAppError("test", "hc_fail", nil, "", 502),
|
||||
)
|
||||
|
||||
// Stop() returns an error (simulates concurrent stop by config
|
||||
// listener). The watcher must tolerate this and still transition
|
||||
// to the retry phase.
|
||||
engineMock.On("Stop").Return(func() *model.AppError {
|
||||
stopped.Store(1)
|
||||
return model.NewAppError("test", "already_stopped", nil, "", 500)
|
||||
})
|
||||
|
||||
// After transitioning to retry phase, Start() succeeds.
|
||||
var started atomic.Int32
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
started.Store(1)
|
||||
return nil
|
||||
}).Maybe()
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
// Watcher should have called Stop() then retried Start().
|
||||
require.Eventually(t, func() bool {
|
||||
return started.Load() == 1
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"watcher should have called Stop then retried Start")
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopSearchEngineWatcher(t *testing.T) {
|
||||
t.Run("exits immediately on stop signal during retry wait", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
// Make retries slow so the watcher is sitting in a timer wait.
|
||||
searchEngineRetryInitial = 10 * time.Second
|
||||
|
||||
engineMock.On("Start", mock.Anything).Return(
|
||||
model.NewAppError("test", "start_failed", nil, "", 500),
|
||||
).Maybe()
|
||||
|
||||
w.start()
|
||||
|
||||
// Should exit well before the 10s retry timer fires.
|
||||
start := time.Now()
|
||||
w.stop()
|
||||
|
||||
assert.Less(t, time.Since(start), 2*time.Second)
|
||||
})
|
||||
|
||||
t.Run("idempotent", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
// Not running — calling stop should be a no-op.
|
||||
require.NotPanics(t, w.stop)
|
||||
require.NotPanics(t, w.stop)
|
||||
|
||||
engineMock.On("Start", mock.Anything).Return(
|
||||
model.NewAppError("test", "start_failed", nil, "", 500),
|
||||
).Maybe()
|
||||
|
||||
// Start, then stop twice — second stop should not panic.
|
||||
w.start()
|
||||
w.stop()
|
||||
require.NotPanics(t, w.stop)
|
||||
})
|
||||
|
||||
t.Run("returns after timeout when watcher is stuck in Start", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
searchEngineStopTimeout = 100 * time.Millisecond
|
||||
|
||||
// Start() blocks forever, simulating a hung connection.
|
||||
blocked := make(chan struct{})
|
||||
enteredStart := make(chan struct{})
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
close(enteredStart)
|
||||
<-blocked
|
||||
return nil
|
||||
})
|
||||
|
||||
w.start()
|
||||
|
||||
// Wait until the watcher is actually stuck inside Start().
|
||||
<-enteredStart
|
||||
|
||||
// Should return after the timeout, not hang forever.
|
||||
start := time.Now()
|
||||
w.stop()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.GreaterOrEqual(t, elapsed, 100*time.Millisecond)
|
||||
assert.Less(t, elapsed, 1*time.Second)
|
||||
|
||||
// Unblock the goroutine so it can be cleaned up.
|
||||
close(blocked)
|
||||
})
|
||||
|
||||
t.Run("start after timeout creates new watcher", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
searchEngineStopTimeout = 50 * time.Millisecond
|
||||
|
||||
// First watcher: Start() blocks forever.
|
||||
blocked := make(chan struct{})
|
||||
enteredStart := make(chan struct{}, 1)
|
||||
var startCalls atomic.Int32
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
n := startCalls.Add(1)
|
||||
if n == 1 {
|
||||
enteredStart <- struct{}{}
|
||||
<-blocked
|
||||
}
|
||||
return model.NewAppError("test", "start_failed", nil, "", 500)
|
||||
})
|
||||
|
||||
w.start()
|
||||
<-enteredStart
|
||||
|
||||
// Save the first goroutine's done channel before stop nils it.
|
||||
w.mu.Lock()
|
||||
firstDone := w.done
|
||||
w.mu.Unlock()
|
||||
|
||||
// Stop times out (watcher stuck in Start).
|
||||
w.stop()
|
||||
|
||||
// Start a new watcher — should work despite the abandoned goroutine.
|
||||
w.start()
|
||||
|
||||
// New watcher should call Start() (the second call).
|
||||
require.Eventually(t, func() bool {
|
||||
return startCalls.Load() >= 2
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"new watcher should have called Start")
|
||||
|
||||
// Unblock the abandoned goroutine and wait for it to fully exit
|
||||
// before the test cleanup restores package-level timing vars.
|
||||
close(blocked)
|
||||
<-firstDone
|
||||
|
||||
w.stop()
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifySearchEngineWatcher(t *testing.T) {
|
||||
t.Run("notify resets backoff and triggers immediate evaluation", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(false).Maybe()
|
||||
|
||||
// Use a long retry so the watcher is in a backoff wait.
|
||||
searchEngineRetryInitial = 10 * time.Second
|
||||
|
||||
var startCalls atomic.Int32
|
||||
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
|
||||
startCalls.Add(1)
|
||||
return model.NewAppError("test", "start_failed", nil, "", 500)
|
||||
})
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
// Wait for first Start() call.
|
||||
require.Eventually(t, func() bool {
|
||||
return startCalls.Load() >= 1
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
// Watcher is now in backoff (10s). Send notify to wake it.
|
||||
before := startCalls.Load()
|
||||
w.reevaluate()
|
||||
|
||||
// Start should be called again quickly (not after 10s).
|
||||
require.Eventually(t, func() bool {
|
||||
return startCalls.Load() > before
|
||||
}, 1*time.Second, 5*time.Millisecond,
|
||||
"notify should have triggered an immediate Start retry")
|
||||
})
|
||||
|
||||
t.Run("no-op when watcher is not running", func(t *testing.T) {
|
||||
w, _ := setupWatcherTest(t)
|
||||
|
||||
// Should not panic or block.
|
||||
require.NotPanics(t, func() {
|
||||
w.reevaluate()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestWatcherHealthFlag(t *testing.T) {
|
||||
t.Run("first health check failure sets engine unhealthy", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(true).Maybe()
|
||||
|
||||
// Track SetHealthy calls.
|
||||
var healthyValue atomic.Int32
|
||||
healthyValue.Store(1) // assume healthy at start
|
||||
engineMock.On("SetHealthy", mock.Anything).Unset()
|
||||
engineMock.On("SetHealthy", mock.Anything).Run(func(args mock.Arguments) {
|
||||
if args.Bool(0) {
|
||||
healthyValue.Store(1)
|
||||
} else {
|
||||
healthyValue.Store(0)
|
||||
}
|
||||
}).Maybe()
|
||||
|
||||
// HealthCheck fails on the first call.
|
||||
engineMock.On("HealthCheck", mock.Anything).Return(func(_ request.CTX) *model.AppError {
|
||||
return model.NewAppError("test", "hc_fail", nil, "", 502)
|
||||
})
|
||||
engineMock.On("Stop").Return(nil).Maybe()
|
||||
engineMock.On("Start", mock.Anything).Return(nil).Maybe()
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
// Wait for the health flag update itself rather than the HealthCheck
|
||||
// call count, which can race with the watcher's SetHealthy(false) call.
|
||||
require.Eventually(t, func() bool {
|
||||
return healthyValue.Load() == 0
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"engine should be marked unhealthy after first health check failure")
|
||||
})
|
||||
|
||||
t.Run("successful health check restores healthy flag", func(t *testing.T) {
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(true).Maybe()
|
||||
|
||||
// Track SetHealthy calls.
|
||||
var healthyValue atomic.Int32
|
||||
engineMock.On("SetHealthy", mock.Anything).Unset()
|
||||
engineMock.On("SetHealthy", mock.Anything).Run(func(args mock.Arguments) {
|
||||
if args.Bool(0) {
|
||||
healthyValue.Store(1)
|
||||
} else {
|
||||
healthyValue.Store(0)
|
||||
}
|
||||
}).Maybe()
|
||||
|
||||
// The engine will be active when the watcher exits, so the
|
||||
// cleanup defer in run() will call Stop().
|
||||
engineMock.On("Stop").Return(nil).Maybe()
|
||||
|
||||
// First health check fails, second succeeds.
|
||||
var healthCalls atomic.Int32
|
||||
engineMock.On("HealthCheck", mock.Anything).Return(func(_ request.CTX) *model.AppError {
|
||||
n := healthCalls.Add(1)
|
||||
if n == 1 {
|
||||
return model.NewAppError("test", "hc_fail", nil, "", 502)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
// Wait for SetHealthy(true) — called by the watcher after the
|
||||
// second (successful) HealthCheck returns.
|
||||
require.Eventually(t, func() bool {
|
||||
return healthyValue.Load() == 1
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"engine should be marked healthy after successful health check")
|
||||
})
|
||||
|
||||
setupHealthCheckLogTest := func(t *testing.T, startHealthy bool) (*searchEngineWatcher, *mlog.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
w, engineMock := setupWatcherTest(t)
|
||||
|
||||
logBuffer := &mlog.Buffer{}
|
||||
require.NoError(t, mlog.AddWriterTarget(w.ps.Logger(), logBuffer, true, mlog.StdAll...))
|
||||
|
||||
engineMock.On("IsEnabled").Return(true).Maybe()
|
||||
engineMock.On("IsActive").Return(true).Maybe()
|
||||
engineMock.On("Stop").Return(nil).Maybe()
|
||||
engineMock.On("Start", mock.Anything).Return(nil).Maybe()
|
||||
|
||||
engineMock.On("IsHealthy").Unset()
|
||||
engineMock.On("IsHealthy").Return(startHealthy)
|
||||
|
||||
if startHealthy {
|
||||
engineMock.On("HealthCheck", mock.Anything).Return(
|
||||
model.NewAppError("test", "hc_fail", nil, "", 502),
|
||||
)
|
||||
} else {
|
||||
engineMock.On("HealthCheck", mock.Anything).Return(nil)
|
||||
}
|
||||
|
||||
return w, logBuffer
|
||||
}
|
||||
|
||||
t.Run("logs healthy to unhealthy transition", func(t *testing.T) {
|
||||
w, logBuffer := setupHealthCheckLogTest(t, true)
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
require.NoError(t, w.ps.Logger().Flush())
|
||||
return testlib.CheckLog(t, strings.NewReader(logBuffer.String()), mlog.LvlWarn.Name,
|
||||
"Search engine health check failed: it is now marked as unhealthy")
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"expected log for healthy->unhealthy transition")
|
||||
})
|
||||
|
||||
t.Run("logs unhealthy to healthy transition", func(t *testing.T) {
|
||||
w, logBuffer := setupHealthCheckLogTest(t, false)
|
||||
|
||||
w.start()
|
||||
t.Cleanup(w.stop)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
require.NoError(t, w.ps.Logger().Flush())
|
||||
return testlib.CheckLog(t, strings.NewReader(logBuffer.String()), mlog.LvlInfo.Name,
|
||||
"Search engine health check succeeded: it is now marked as healthy")
|
||||
}, 2*time.Second, 5*time.Millisecond,
|
||||
"expected log for unhealthy->healthy transition")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user