Keep fixture alerts from outliving a mock-mode switch

SetMockMode(false) switched the mock package off and then cleared active
alerts, but every path that evaluates mode-dependent data checked the
mode once, up front. A mock alert pass (started from the ticker with go,
so passes overlap), a frontend or ingest refresh evaluating a GetState
snapshot, a backup-age check over fixture recovery rollups, or a
connection check over the mock ledger could already hold fixture data
when the switch happened. Its evaluations then landed after the clear,
in live mode, where nothing evaluates or resolves fixture resources, so
the alerts stayed until the 24-hour stale sweep.

A pass-level lock around checkMockAlerts was tried and reverted: it left
the other paths open and made the mock-mode API wait behind a whole pass
of synchronous event-log writes.

The monitor now owns a mock-mode epoch fence. The mock pass, backup-age
and snapshot checks, connection checks, Docker pruning and the
resource-store refresh take a scope before reading anything
mode-dependent and send each alert-manager call through it. SetMockMode,
now serialized, flips the mode, ends the epoch, waits only for calls
already admitted, and then clears. Unified metrics are evaluated one
resource per call, so the wait covers single calls, not passes.

Registry rebuilds are tracked rather than waited on. A refresh evaluates
the registry it read back only if no rebuild from an ended epoch
overlapped it, and the readers that open or remove alerts from the
inventory use current state until a clean rebuild in the new epoch
replaces the previous mode's registry. The mock supplemental fixture
sources now serve nothing outside mock mode: the router swaps them for
the real pollers only after the switch, refreshing between sources, so
a single switch to live mode used to raise fixture vSphere incidents.

The new tests park a real evaluation at a fixed point (a held lock or a
blocking supplier), switch modes and release it. Against the unfenced
code the mock pass left about 40 fixture alerts, the frontend refresh 5
vSphere incidents and the backup check 137 backup-age alerts in live
mode, and a mock-mode connection observation counted toward a live
connection's confirmation. The drain, republish and pruning tests fail
against a fence without its wait, overlap check or registry tracking.
Switching mock mode with several tenant monitors running remains
uncovered and is noted in the monitoring contract.
This commit is contained in:
rcourtman
2026-10-07 12:03:38 +01:00
parent d5c945241d
commit 06b7f5a0b9
24 changed files with 1342 additions and 178 deletions
@@ -744,6 +744,16 @@ through `GetLiveHostsSnapshot`, which copies only hosts (see the monitoring
contract); it must not route through a full state snapshot, which copied every
guest on each agent report.
The mock toggle keeps the same boundary for evaluations already in flight.
`SetMockMode` ends the mock-mode epoch before it clears alerts and forgets the
fixture agents (see the monitoring contract), so a fixture agent pass that read
the estate before the toggle cannot reopen the agent's alerts or re-register
its hostname deduplication afterwards. The toggle waits for the
alert-manager calls already running, not for whole passes. Report admission is
not fenced: a live report already past its mock-mode check when mock mode is
switched on can still be evaluated after the clear, and its alerts can persist
until mock mode is left.
Physical-disk evidence collected by a host agent must survive projection back
into monitoring's models. Absent evidence has to carry its declared sentinel
rather than a zero value that reads as a real measurement: an absent
@@ -1663,10 +1663,12 @@ boundaries:
- Leaving mock mode routes the fixture agents through `HandleHostRemoved`.
`ClearActiveAlerts` drops their alerts but not their hostname registrations,
and a real node named like a fixture agent (`pve1`) would otherwise keep its
metric alerts suppressed with no agent to own them. A pass whose snapshot
predates the switch finds mock mode off under the same lock and evaluates
nothing. Two overlapping passes can still evaluate an older snapshot after
a newer one; the next pass reconciles the set again.
metric alerts suppressed with no agent to own them. `SetMockMode` ends the
mock-mode epoch before it forgets them, so a pass whose snapshot predates the
switch finds its epoch ended under the same lock and evaluates nothing, and
an agent whose departure the switch refused stays in the forgotten set. Two
overlapping passes in one epoch can still evaluate an older snapshot after a
newer one; the next pass reconciles the set again.
Fixture agents must carry readings a real agent could report. Mock Kubernetes
pods share 0.7 single-pod memory footprints per node, which keeps a node's pods
@@ -1721,14 +1723,115 @@ boundary around it:
matches node names only, so it relies on fixture node names being unique
across instances, which they are.
The disk loop relies on the pass's single up-front mode check, like every
loop in `checkMockAlerts` except the host-agent step, which re-checks under its
own lock. A pass already past that check when mock mode is switched off can
reopen its fixture alerts after `SetMockMode(false)` clears them, and
other paths that evaluate fixture data (frontend refresh into unified alert
evaluation, backup rollups) have the same window. That mode-exit race predates
the disk loop and is not closed here; a lock around `checkMockAlerts` alone
would leave the other paths open.
Switching mock mode fences the alert evaluations that read mode-dependent data
(`mockModeFence`, `internal/monitoring/mock_mode_fence.go`). `GetState`, the
fixture graph, the unified read view, the recovery rollups and the connection
ledger all change source when the mode flips. A pass already holding one side's
data used to reach the alert manager after `SetMockMode` cleared it, and its
fixture alerts then stayed in live mode, with nothing left to evaluate or
resolve them, until the 24-hour stale sweep. Each of these paths takes a scope
before it reads anything mode-dependent and sends its alert-manager calls
through it:
- `checkMockAlerts`, for every loop including the host-agent and disk steps
and the mock alert snapshot cache;
- `checkBackupAlerts`, shared by the PVE, PBS and mock polls, and
`pollGuestSnapshots`, whose guest inventory is the fixture read view in mock
mode;
- `checkConnectionAlerts`, whose ledger lists fixture connections in mock mode;
- `pruneStaleDockerAlerts`, which removes Docker alerts for hosts missing from
the read view;
- the resource-store refresh. `updateResourceStore` and
`updateResourceStoreForRead` take the scope of the state they rebuild from
(`currentStateWithScope` for `GetState`), and the unified alert sync
(`syncUnifiedResourceAlertsToState`) runs its policy reconcile, identity and
availability-link migrations, per-resource metrics and incident sync under
it.
`SetMockMode` flips the mode, ends the epoch, waits for the alert-manager calls
already admitted under it and only then clears, so nothing these paths read
before the flip lands after the clear, in either direction. Switches are
serialized. The wait covers the calls already running, not whole passes, but it
has no fixed wall-clock bound: backup evaluation and unified incident sync are
estate-wide calls, and an admitted call can include metric-window reads and
migration persistence. Unified metrics are evaluated one resource per call, and
reads, capacity-trend queries and registry rebuilds stay outside the wait.
Admission never blocks, so a call made inside an admitted one cannot deadlock a
waiting switch; `SetMockMode` itself must not be called from inside an
evaluation.
A registry rebuild publishes shared state, so a rebuild holding the mode the
monitor left can finish after a newer refresh published and replace the
registry that refresh reads back. A rebuild whose epoch has ended does not
start, and one already running is tracked: a refresh evaluates the registry it
read back only if no ended-epoch rebuild overlapped it, and otherwise leaves the
alerts to the next refresh. Even without a late rebuild, the registry still
holds the fixture estate right after leaving mock mode, and may hold it until an
undisturbed rebuild in the new epoch has replaced it
(`mockModeFence.registryCurrent`); in mock mode the read paths use the fixture
view instead. The live readers that act on that inventory (Docker pruning, the
backup-age guest lookup, the guest inventory of the backup and snapshot
pollers, and the pollers' previous-node and previous-guest carry, which writes
back into live state) read it through `currentModeReadState`, which serves a
view of current monitor state meanwhile. That view omits provider-owned
supplemental resources and persisted manual agent links, so a carry in the
window can fall back to platform guest memory for one poll, and it copies the
estate, so only callers outside per-resource loops use it. Every other live
registry reader (`GetUnifiedReadStateOrSnapshot` callers such as metrics
targets and system usage, and the broadcast view) may still see the fixture
estate until the next rebuild, as before, and metric samples synced from a late
rebuild stay in metric history.
The fixture sources behind the router's mock supplemental adapters
(`mock.SupplementalRecords`, `mock.SupplementalChanges`) serve nothing while
mock mode is off. The router swaps those adapters for the real TrueNAS and
vSphere pollers only after `SetMockMode` returns, one source at a time with a
refresh after each, and the adapters used to fall back to default fixtures, so
a single switch to live mode raised fixture vSphere incidents. Two concurrent
mock-mode requests can still leave the router's provider set disagreeing with
the mode (fixture adapters serving nothing in live mode, or real pollers
feeding mock mode); neither raises fixture alerts in live mode.
Report admission and the live pollers outside the paths above are not fenced:
a live report or poll already past its mock-mode check when mock mode is
switched on can still evaluate after that clear, and its alerts can persist
until mock mode is left.
The fence and the clear are per monitor while the mode is process-wide. A
tenant monitor other than the one `SetMockMode` was called on neither drains
nor clears, so fixture alerts it raised outlive the switch as they did before
the fence; switching mock mode with several tenant monitors running is not
covered.
The proofs park a real evaluation at a fixed point, switch modes and release
it. `TestLeavingMockModeRefusesTheRestOfAnInFlightPass` holds a mock pass at its
first storage capacity read, `TestLeavingMockModeDiscardsAnInFlightFrontendRefresh`
holds a frontend read in its supplemental-records supply,
`TestLeavingMockModeDiscardsAnInFlightBackupEvaluation` holds a backup
evaluation at its guest lookup, and
`TestLeavingMockModeDiscardsAnInFlightConnectionCheck` holds the ledger read.
Against the unfenced code they left about 40 fixture storage, disk and Docker
alerts, 5 vSphere incidents and 137 backup-age alerts in live mode, and counted
a mock-mode observation toward a live connection's three-observation
confirmation. `TestLeavingMockModeWaitsForAnInFlightEvaluation` holds an
evaluation in its CPU window read, outside the alert manager's lock, and fails
if the switch returns first; `TestLiveRefreshIgnoresARegistryRepublishedFromMockMode`
lets a mock-mode rebuild republish while a live refresh is held before reading
the registry back, then checks the next live refresh evaluates live records.
Each of those two fails against a fence without its wait or its overlap check.
`TestDockerPruneIgnoresTheRegistryMockModeLeft` raises a live Docker alert
before any live rebuild and fails if pruning reads the fixture registry, and
`TestPreviousStateCarryIgnoresTheRegistryMockModeLeft` fails if the
previous-state carry returns fixture nodes or guests. All eight are in
`internal/monitoring/monitor_mock_alerts_test.go`;
`TestSupplementalFixturesServeNothingOutsideMockMode` in
`internal/mock/platform_fixtures_test.go` pins the fixture sources;
`TestMockModeFence*` in
`internal/monitoring/mock_mode_fence_test.go` pin the fence itself, and
`TestStoreRefreshTakesItsMockModeScopeBeforeReadingState` in
`internal/monitoring/canonical_guardrails_test.go` rejects a call that reads
state in an argument ahead of `m.mockModeFence.begin()` and pins the
end-epoch-then-clear order in `SetMockMode`.
The disk evaluation runs on the mock alert tick, not the poller's disk
interval (five minutes by default, `PhysicalDiskPollingMinutes` per instance).
+10 -23
View File
@@ -119,37 +119,24 @@ func defaultTrueNASConnectionFixture(fixtures PlatformFixtures) TrueNASConnectio
}
}
// SupplementalRecords serves the fixture graph's provider-backed records while
// mock mode is on and nothing once it is off. The router's mock adapters stay
// registered for a moment after leaving mock mode, and a live refresh that
// still reached them must not ingest and alert on fixture resources.
func SupplementalRecords(source unifiedresources.DataSource) []unifiedresources.IngestRecord {
if IsMockEnabled() {
return CurrentFixtureGraph().SupplementalRecords(source)
}
platformFixtures := defaultPlatformFixtures()
now := time.Now().UTC()
switch normalizeSupplementalSource(source) {
case unifiedresources.SourceTrueNAS:
return truenas.FixtureRecords(platformFixtures.TrueNAS)
case unifiedresources.SourceVMware:
return vmware.FixtureRecords(platformFixtures.VMware)
case unifiedresources.SourceAvailability:
return availabilityFixtureRecords(defaultAvailabilityFixtures(now), now)
default:
if !IsMockEnabled() {
return nil
}
return CurrentFixtureGraph().SupplementalRecords(source)
}
// SupplementalChanges serves the fixture graph's provider activity while mock
// mode is on and nothing once it is off, for the same reason.
func SupplementalChanges(source unifiedresources.DataSource) []unifiedresources.ResourceChange {
if IsMockEnabled() {
return CurrentFixtureGraph().SupplementalChanges(source)
}
platformFixtures := defaultPlatformFixtures()
switch normalizeSupplementalSource(source) {
case unifiedresources.SourceVMware:
return vmware.FixtureActivityChanges(platformFixtures.VMware)
default:
if !IsMockEnabled() {
return nil
}
return CurrentFixtureGraph().SupplementalChanges(source)
}
func SupplementalOwnedSources() []unifiedresources.DataSource {
+29
View File
@@ -232,13 +232,42 @@ func TestUnifiedResourceSnapshotParentsDemoProxmoxWorkloads(t *testing.T) {
}
func TestSupplementalRecordsNormalizesVMwareAlias(t *testing.T) {
previous := IsMockEnabled()
mustSetEnabled(t, true)
t.Cleanup(func() { mustSetEnabled(t, previous) })
records := SupplementalRecords(unifiedresources.DataSource("vmware-vsphere"))
if len(records) == 0 {
t.Fatal("expected records for vmware-vsphere alias")
}
}
// The router's mock adapters outlive mock mode briefly while it swaps the real
// pollers back in; a live refresh that reaches them must find no fixtures.
func TestSupplementalFixturesServeNothingOutsideMockMode(t *testing.T) {
previous := IsMockEnabled()
mustSetEnabled(t, false)
t.Cleanup(func() { mustSetEnabled(t, previous) })
for _, source := range []unifiedresources.DataSource{
unifiedresources.SourceTrueNAS,
unifiedresources.SourceVMware,
unifiedresources.SourceAvailability,
} {
if records := SupplementalRecords(source); len(records) != 0 {
t.Errorf("SupplementalRecords(%s) served %d fixture records outside mock mode", source, len(records))
}
if changes := SupplementalChanges(source); len(changes) != 0 {
t.Errorf("SupplementalChanges(%s) served %d fixture changes outside mock mode", source, len(changes))
}
}
}
func TestSupplementalChangesNormalizesVMwareAlias(t *testing.T) {
previous := IsMockEnabled()
mustSetEnabled(t, true)
t.Cleanup(func() { mustSetEnabled(t, previous) })
changes := SupplementalChanges(unifiedresources.DataSource("vmware-vsphere"))
if len(changes) == 0 {
t.Fatal("expected activity changes for vmware-vsphere alias")
+2 -2
View File
@@ -415,7 +415,7 @@ func (m *Monitor) RefreshAvailabilityTargets() {
}
m.refreshInstanceInfoCacheFromProviders()
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
}
func (m *Monitor) pollAvailabilityTarget(ctx context.Context, target config.AvailabilityTarget) {
@@ -425,7 +425,7 @@ func (m *Monitor) pollAvailabilityTarget(ctx context.Context, target config.Avai
latency := time.Since(start)
checkedAt := time.Now().UTC()
m.applyAvailabilityObservationDetailed(target, uuid.NewString(), checkedAt, latency, result.Outcome, result.TransportOutcome, result.Application, err, result.Certificate, "", time.Time{})
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
}
// applyAvailabilityObservation records one availability observation regardless of
@@ -205,7 +205,7 @@ func (m *Monitor) applyProbeAvailabilityResultsAt(hostID string, results []Probe
if applied == 0 {
return
}
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
}
func applicationResultFromReport(entry agentshost.AvailabilityProbeResult) *availabilityprobe.ApplicationResult {
@@ -90,7 +90,7 @@ func TestBroadcastMetricsSnapshotConnectedContent(t *testing.T) {
if freshness := latestUnifiedResourceLastSeen(adapter.GetAll()); !freshness.IsZero() {
want.LastUpdate = freshness.UnixMilli()
}
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(graph.State, func() time.Time { return evaluationTime })
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(graph.State, m.mockModeFence.begin(), func() time.Time { return evaluationTime })
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
@@ -142,7 +142,7 @@ func TestBroadcastMetricsSnapshotHealthClockBoundary(t *testing.T) {
detail string
}{{59 * time.Second, "0m"}, {61 * time.Second, "1m"}} {
calls := 0
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(models.EmptyStateSnapshot(), func() time.Time {
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(models.EmptyStateSnapshot(), m.mockModeFence.begin(), func() time.Time {
calls++
return original.Add(tc.age)
})
@@ -301,7 +301,7 @@ func BenchmarkBroadcastMetricsSnapshot(b *testing.B) {
b.StartTimer()
}
if bulk {
completeProjectionSink = m.buildBroadcastFrontendStateFromSnapshot(graph.State)
completeProjectionSink = m.buildBroadcastFrontendStateFromSnapshot(graph.State, m.mockModeFence.begin())
} else {
completeProjectionSink = parentFrontendProjectionForTest(m, graph.State, adapter.GetAll(), adapter, time.Now().UTC())
}
@@ -498,7 +498,7 @@ func TestUnifiedResourceAlertSyncEvaluatesMetricsBeforeIncidents(t *testing.T) {
}
source := string(data)
metricIndex := strings.Index(source, "CheckUnifiedResourceMetricsWithCapacityTrends(resources")
metricIndex := strings.Index(source, "CheckUnifiedResourceWithCapacityTrend(input, capacityTrends[input.ID])")
incidentIndex := strings.Index(source, "SyncUnifiedResourceIncidents(resources)")
if metricIndex < 0 {
t.Fatalf("monitor alert sync must run unified resource metric evaluation")
@@ -511,6 +511,43 @@ func TestUnifiedResourceAlertSyncEvaluatesMetricsBeforeIncidents(t *testing.T) {
}
}
// Monitor state and live snapshots switch to the fixture graph in mock mode,
// so a store refresh must take its mock-mode scope before it reads them.
// Go evaluates call arguments left to right: a scope begun in the argument
// after the read belongs to the epoch the snapshot may already have left.
func TestStoreRefreshTakesItsMockModeScopeBeforeReadingState(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatalf("glob monitoring sources: %v", err)
}
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
source := string(data)
for _, forbidden := range []string{
"GetState(), m.mockModeFence.begin()",
"GetSnapshot(), m.mockModeFence.begin()",
} {
if strings.Contains(source, forbidden) {
t.Errorf("%s reads state before taking its mock-mode scope (%q); use currentStateWithScope or begin the scope first", file, forbidden)
}
}
}
data, err := os.ReadFile("monitor.go")
if err != nil {
t.Fatalf("failed to read monitor.go: %v", err)
}
if got := strings.Count(string(data), "m.mockModeFence.advance()\n\t\tm.alertManager.ClearActiveAlerts()"); got != 2 {
t.Fatalf("SetMockMode must end the mock-mode epoch immediately before clearing alerts in both directions, found %d", got)
}
}
func TestStateBroadcastTreatsTypedNilHubAsAbsent(t *testing.T) {
data, err := os.ReadFile("monitor.go")
if err != nil {
@@ -2959,7 +2996,7 @@ func TestBroadcastProjectionMatchesPreviousPipeline(t *testing.T) {
if !view.freshness.IsZero() {
want.LastUpdate = view.freshness.UnixMilli()
}
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(snapshot, func() time.Time { return now })
got := m.buildBroadcastFrontendStateFromSnapshotWithClock(snapshot, m.mockModeFence.begin(), func() time.Time { return now })
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
@@ -79,7 +79,7 @@ func testGuestMemoryObservationLifecycle(t *testing.T) {
assertGuestMemoryObservation(t, *legacy.Memory, state, source, at)
// REST and WebSocket state use this production conversion, including
// registry cloning and the nested Proxmox payload the drawer consumes.
front := m.buildBroadcastFrontendStateFromSnapshot(models.StateSnapshot{VMs: []models.VM{vm}})
front := m.buildBroadcastFrontendStateFromSnapshot(models.StateSnapshot{VMs: []models.VM{vm}}, m.mockModeFence.begin())
data, err := json.Marshal(front.Resources)
if err != nil {
t.Fatal(err)
+196
View File
@@ -0,0 +1,196 @@
package monitoring
import (
"sync"
"sync/atomic"
)
// mockModeFence keeps alert evaluations of mode-dependent data inside the
// mock-mode epoch they read it in. GetState, the fixture graph, recovery
// rollups and the connection ledger all switch source when mock mode flips,
// and a pass already holding one side's data can reach the alert manager
// after SetMockMode cleared it. Its fixture alerts then outlive mock mode,
// with nothing left to evaluate or resolve them.
//
// A pass takes a scope before it reads anything mode-dependent and sends each
// alert-manager call through scope.run. SetMockMode flips the mode, advances
// the fence and only then clears: advance refuses every later call made under
// an older epoch and returns once the calls already admitted have finished,
// so nothing a pass read before the flip lands after the clear. advance waits
// for the alert-manager calls already admitted, not for whole passes; reads,
// metric queries and registry rebuilds stay outside it.
//
// A registry rebuild publishes shared state that a later refresh reads back
// and evaluates, so a rebuild still running when the epoch ends can replace a
// newer refresh's registry with the mode the monitor left. Rebuilds go
// through scope.publish, which advance does not wait for (a rebuild can spend
// seconds in SQLite), and a refresh evaluates what it read back only while
// undisturbedSince confirms no ended-epoch publish overlapped it. Readers
// that use the registry outside a refresh ask registryCurrent: after a switch
// the registry still holds the estate of the mode the monitor left, until an
// undisturbed publish in the new epoch replaces it.
//
// Admission never blocks, so a call that re-enters the fence (an alert
// callback that refreshes the resource store) cannot deadlock against a
// waiting advance; an inner call made under an ended epoch is refused like
// any other. advance must not be reached from inside an admitted call. The
// zero value is ready to use.
type mockModeFence struct {
mu sync.Mutex
drained *sync.Cond
epoch uint64
inFlight map[uint64]int
// publishing counts registry publishes by epoch; lateFinishes counts
// publishes that finished after their epoch ended.
publishing map[uint64]int
lateFinishes uint64
// cleanEpoch and cleanLate record the last undisturbed replacement;
// registryStale caches whether the registry is current for lock-free
// readers. Their zero values make a never-switched monitor's registry
// current.
cleanEpoch uint64
cleanLate uint64
registryStale atomic.Bool
}
// mockModeScope is the epoch one pass read its data in.
type mockModeScope struct {
fence *mockModeFence
epoch uint64
}
// begin opens a scope in the current epoch. Callers must take it before they
// read the mock mode or any data whose source depends on it.
func (f *mockModeFence) begin() mockModeScope {
f.mu.Lock()
defer f.mu.Unlock()
return mockModeScope{fence: f, epoch: f.epoch}
}
// advance ends the current epoch and waits until no call admitted under an
// ended epoch is still running.
func (f *mockModeFence) advance() {
f.mu.Lock()
defer f.mu.Unlock()
f.epoch++
f.registryStale.Store(true)
for f.staleInFlightLocked() {
if f.drained == nil {
f.drained = sync.NewCond(&f.mu)
}
f.drained.Wait()
}
}
func (f *mockModeFence) staleInFlightLocked() bool {
for epoch := range f.inFlight {
if epoch < f.epoch {
return true
}
}
return false
}
// current reports whether the scope's epoch is still the fence's epoch.
func (s mockModeScope) current() bool {
s.fence.mu.Lock()
defer s.fence.mu.Unlock()
return s.fence.epoch == s.epoch
}
// run calls fn if the scope's epoch is still current and reports whether it
// did. A mode change that starts while fn runs waits for it to return.
func (s mockModeScope) run(fn func()) bool {
f := s.fence
f.mu.Lock()
if f.epoch != s.epoch {
f.mu.Unlock()
return false
}
if f.inFlight == nil {
f.inFlight = make(map[uint64]int)
}
f.inFlight[s.epoch]++
f.mu.Unlock()
defer func() {
f.mu.Lock()
defer f.mu.Unlock()
if f.inFlight[s.epoch]--; f.inFlight[s.epoch] == 0 {
delete(f.inFlight, s.epoch)
}
if f.drained != nil {
f.drained.Broadcast()
}
}()
fn()
return true
}
// publish runs fn, which replaces state that later refreshes read back (the
// unified resource registry) and reports whether it did, unless the scope's
// epoch has ended. It returns the mark undisturbedSince compares against.
func (s mockModeScope) publish(fn func() bool) (mark uint64, ok bool) {
f := s.fence
f.mu.Lock()
if f.epoch != s.epoch {
f.mu.Unlock()
return 0, false
}
if f.publishing == nil {
f.publishing = make(map[uint64]int)
}
f.publishing[s.epoch]++
mark = f.lateFinishes
f.mu.Unlock()
replaced := false
defer func() {
f.mu.Lock()
defer f.mu.Unlock()
if f.publishing[s.epoch]--; f.publishing[s.epoch] == 0 {
delete(f.publishing, s.epoch)
}
switch {
case f.epoch != s.epoch:
f.lateFinishes++
case replaced && f.lateFinishes == mark && !f.olderPublishingLocked():
f.cleanEpoch = s.epoch
f.cleanLate = f.lateFinishes
}
f.registryStale.Store(!f.registryCurrentLocked())
}()
replaced = fn()
return mark, true
}
// undisturbedSince reports whether no publish from an ended epoch has finished
// since mark or is still running. A refresh that read the registry back after
// its own publish evaluates it only then; otherwise the registry may hold the
// data of the mode the monitor left.
func (s mockModeScope) undisturbedSince(mark uint64) bool {
f := s.fence
f.mu.Lock()
defer f.mu.Unlock()
return f.lateFinishes == mark && !f.olderPublishingLocked()
}
// registryCurrent reports whether the registry's last replacement was an
// undisturbed publish in the current epoch and no ended-epoch publish is still
// running. Until then it may hold the estate of the mode the monitor left.
func (f *mockModeFence) registryCurrent() bool {
return !f.registryStale.Load()
}
func (f *mockModeFence) registryCurrentLocked() bool {
return f.cleanEpoch == f.epoch && f.cleanLate == f.lateFinishes && !f.olderPublishingLocked()
}
func (f *mockModeFence) olderPublishingLocked() bool {
for epoch := range f.publishing {
if epoch < f.epoch {
return true
}
}
return false
}
+120
View File
@@ -0,0 +1,120 @@
package monitoring
import (
"testing"
"time"
)
func TestMockModeFenceRefusesCallsFromAnEndedEpoch(t *testing.T) {
var fence mockModeFence
stale := fence.begin()
fence.advance()
if stale.current() {
t.Fatal("a scope from an ended epoch still reads as current")
}
if stale.run(func() { t.Fatal("a call from an ended epoch ran") }) {
t.Fatal("run reported a refused call as admitted")
}
ran := false
if !fence.begin().run(func() { ran = true }) || !ran {
t.Fatal("a scope taken after the epoch ended was refused")
}
}
func TestMockModeFenceAdvanceWaitsForAdmittedCalls(t *testing.T) {
var fence mockModeFence
scope := fence.begin()
entered, release := make(chan struct{}), make(chan struct{})
nestedAdmitted := make(chan bool, 1)
callDone := make(chan struct{})
go func() {
defer close(callDone)
scope.run(func() {
close(entered)
<-release
// A call made from inside an admitted one, after the epoch
// ended, is refused rather than deadlocking the waiting advance.
nestedAdmitted <- scope.run(func() {})
})
}()
waitForSignal(t, entered, 10*time.Second, "admitted call never started")
advanced := make(chan struct{})
go func() {
defer close(advanced)
fence.advance()
}()
waitForCondition(t, 10*time.Second, func() bool { return !scope.current() }, "advance never ended the epoch")
select {
case <-advanced:
t.Fatal("advance returned while a call admitted under the ended epoch was still running")
default:
}
if scope.run(func() { t.Error("a call started after the epoch ended ran") }) {
t.Fatal("run admitted a call after the epoch ended")
}
close(release)
waitForSignal(t, callDone, 10*time.Second, "admitted call did not finish")
waitForSignal(t, advanced, 10*time.Second, "advance did not return once the admitted call finished")
if <-nestedAdmitted {
t.Fatal("a nested call made after the epoch ended was admitted")
}
}
func TestMockModeFenceFlagsReadsOverlappedByAnEndedEpochPublish(t *testing.T) {
var fence mockModeFence
if !fence.registryCurrent() {
t.Fatal("a monitor that never switched modes distrusts its registry")
}
stale := fence.begin()
entered, release := make(chan struct{}), make(chan struct{})
staleDone := make(chan struct{})
go func() {
defer close(staleDone)
stale.publish(func() bool {
close(entered)
<-release
return true
})
}()
waitForSignal(t, entered, 10*time.Second, "publish never started")
// advance does not wait for publishes.
advanced := make(chan struct{})
go func() {
defer close(advanced)
fence.advance()
}()
waitForSignal(t, advanced, 10*time.Second, "advance waited for a publish")
if fence.registryCurrent() {
t.Fatal("the registry counted as current right after a switch")
}
if _, ok := stale.publish(func() bool { t.Error("a publish from an ended epoch ran"); return true }); ok {
t.Fatal("publish admitted an ended epoch")
}
live := fence.begin()
mark, ok := live.publish(func() bool { return true })
if !ok {
t.Fatal("publish refused the current epoch")
}
if live.undisturbedSince(mark) || fence.registryCurrent() {
t.Fatal("a publish overlapped by a running ended-epoch publish counted as undisturbed")
}
close(release)
waitForSignal(t, staleDone, 10*time.Second, "ended-epoch publish did not finish")
if live.undisturbedSince(mark) || fence.registryCurrent() {
t.Fatal("a publish overlapped by a finished ended-epoch publish counted as undisturbed")
}
// A refresh that skipped its rebuild replaced nothing.
if _, ok := live.publish(func() bool { return false }); !ok || fence.registryCurrent() {
t.Fatal("a publish that replaced nothing made the registry current")
}
next, ok := live.publish(func() bool { return true })
if !ok || !live.undisturbedSince(next) || !fence.registryCurrent() {
t.Fatal("a rebuild after the ended-epoch publish finished did not make the registry current")
}
}
+118 -55
View File
@@ -1282,6 +1282,7 @@ type Monitor struct {
mockMetricsWg sync.WaitGroup
mockHostAgentsMu sync.Mutex
mockHostAgents map[string]models.Host // Fixture agents evaluated by the last mock alert pass
mockModeFence mockModeFence // Keeps mode-dependent alert evaluations inside the epoch they read in
dockerChecker DockerChecker // Optional Docker checker for LXC containers
dockerCheckerConfiguredAt time.Time // Last time the Docker checker was configured
dockerCheckAllowedVMIDs map[int]struct{} // Optional VMID allowlist gating the LXC Docker socket probe; empty means all guests
@@ -4360,30 +4361,30 @@ func (m *Monitor) PBSBackupsSnapshot() []models.PBSBackup {
// BuildFrontendState returns the current state converted to frontend format.
// This replaces the GetState().ToFrontend() pattern in consumer code.
func (m *Monitor) BuildFrontendState() models.StateFrontend {
return m.buildBroadcastFrontendStateFromSnapshot(m.GetState())
return m.buildBroadcastFrontendStateFromSnapshot(m.currentStateWithScope())
}
// BuildBroadcastFrontendState returns frontend state ready for websocket
// broadcasts, including the unified resource payload when a resource store is
// configured.
func (m *Monitor) BuildBroadcastFrontendState() models.StateFrontend {
return m.buildBroadcastFrontendStateFromSnapshot(m.GetState())
return m.buildBroadcastFrontendStateFromSnapshot(m.currentStateWithScope())
}
func buildFrontendStateFromSnapshot(snapshot models.StateSnapshot) models.StateFrontend {
return snapshot.ToFrontend()
}
func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateSnapshot) models.StateFrontend {
return m.buildBroadcastFrontendStateFromSnapshotWithClock(snapshot, time.Now)
func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateSnapshot, scope mockModeScope) models.StateFrontend {
return m.buildBroadcastFrontendStateFromSnapshotWithClock(snapshot, scope, time.Now)
}
// The clock is sampled at the same post-read health-evaluation boundary as
// ordinary broadcasts. Tests can compare complete projections at one instant
// without mistaking a naturally advancing health-age label for lost content.
func (m *Monitor) buildBroadcastFrontendStateFromSnapshotWithClock(snapshot models.StateSnapshot, healthClock func() time.Time) models.StateFrontend {
func (m *Monitor) buildBroadcastFrontendStateFromSnapshotWithClock(snapshot models.StateSnapshot, scope mockModeScope, healthClock func() time.Time) models.StateFrontend {
frontendState := buildFrontendStateFromSnapshot(snapshot)
m.updateResourceStoreForRead(snapshot)
m.updateResourceStoreForRead(snapshot, scope)
if m != nil && m.alertManager != nil {
if liveAlerts := m.activeAlertsSnapshot(); len(liveAlerts) > 0 || len(frontendState.ActiveAlerts) > 0 {
frontendState.ActiveAlerts = liveAlerts
@@ -4576,19 +4577,33 @@ func (m *Monitor) broadcastEscalatedAlert(hub *websocket.Hub, alert *alerts.Aler
hub.BroadcastAlertToTenant(m.GetOrgID(), alert)
}
// mockModeSwitchMu serializes mock-mode switches. The mode is process-wide,
// and a switch is several steps (flip, end the epoch, clear, reset state), so
// two interleaved switches could clear what the later one just admitted.
var mockModeSwitchMu sync.Mutex
// SetMockMode switches between mock data and real infrastructure data at runtime.
// It must not be called from inside an alert evaluation, whose completion it
// may wait for.
func (m *Monitor) SetMockMode(enable bool) error {
mockModeSwitchMu.Lock()
defer mockModeSwitchMu.Unlock()
current := mock.IsMockEnabled()
if current == enable {
log.Info().Bool("mockMode", enable).Msg("mock mode already in desired state")
return nil
}
// Every evaluation of mode-dependent data that started before the flip
// must finish or be refused before the clear, or it reopens alerts for
// the side the monitor just left (see mockModeFence).
if enable {
m.stopMockMetricsSampler()
if err := mock.SetEnabled(true); err != nil {
return err
}
m.mockModeFence.advance()
m.alertManager.ClearActiveAlerts()
m.mu.Lock()
m.resetStateLocked()
@@ -4607,6 +4622,7 @@ func (m *Monitor) SetMockMode(enable bool) error {
if err := mock.SetEnabled(false); err != nil {
return err
}
m.mockModeFence.advance()
m.alertManager.ClearActiveAlerts()
m.forgetMockHostAgents()
m.mu.Lock()
@@ -4750,7 +4766,7 @@ func (m *Monitor) SetResourceStore(store ResourceStoreInterface) {
// Guard against minimally initialized monitors (e.g., test fixtures
// with bare &Monitor{}) where m.state may be nil.
if store != nil && m.state != nil {
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
}
}
@@ -4777,7 +4793,7 @@ func (m *Monitor) SetSupplementalRecordsProvider(source unifiedresources.DataSou
}
m.mu.Unlock()
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
}
// SetLicenseChecker wires the commercial feature gate used by monitoring-owned
@@ -5153,6 +5169,25 @@ func (m *Monitor) GetUnifiedReadStateOrSnapshot() unifiedresources.ReadState {
return m.currentUnifiedStateView().readState
}
// currentModeReadState is GetUnifiedReadStateOrSnapshot for callers that act
// on the inventory it lists: those that open or remove alerts from it (Docker
// pruning, backup and snapshot guest lookups) and the pollers' previous-state
// carry, which writes it back into live state. After leaving mock mode the
// registry may still hold the fixture estate until an undisturbed rebuild in
// the new epoch replaces it, so they get a view of current state meanwhile.
// The view lists the state snapshot without provider-owned supplemental
// resources or persisted manual links and copies the whole estate, so it is
// only for callers outside per-resource loops.
func (m *Monitor) currentModeReadState() unifiedresources.ReadState {
if m == nil {
return nil
}
if !mock.IsMockEnabled() && m.GetUnifiedReadState() != nil && !m.mockModeFence.registryCurrent() {
return m.unifiedStateViewWithStandaloneHostContinuity(monitorUnifiedStateViewFromSnapshot(m.GetState())).readState
}
return m.GetUnifiedReadStateOrSnapshot()
}
// shouldSkipNodeMetrics returns true if we should skip detailed metric polling
// for the given node because a host agent is providing richer data.
// This helps reduce API load when agents are active.
@@ -5194,7 +5229,7 @@ type readRefreshResourceStore interface {
// above all must not queue behind an in-flight rebuild whose change-record
// and identity-pin persistence can take seconds per transaction on slow
// volumes (#1665: /api/state stuck for minutes with SQLite on NFS).
func (m *Monitor) updateResourceStoreForRead(state models.StateSnapshot) {
func (m *Monitor) updateResourceStoreForRead(state models.StateSnapshot, scope mockModeScope) {
m.mu.RLock()
store := m.resourceStore
m.mu.RUnlock()
@@ -5204,7 +5239,7 @@ func (m *Monitor) updateResourceStoreForRead(state models.StateSnapshot) {
}
readStore, ok := store.(readRefreshResourceStore)
if !ok {
m.updateResourceStore(state)
m.updateResourceStore(state, scope)
return
}
@@ -5215,20 +5250,46 @@ func (m *Monitor) updateResourceStoreForRead(state models.StateSnapshot) {
if ownedSources := m.providerOwnedSnapshotSources(); len(ownedSources) > 0 {
snapshotForStore = unifiedresources.SnapshotWithoutSources(state, ownedSources)
}
rebuilt := readStore.TryReplaceRegistryForRead(snapshotForStore, readPathRegistryFreshness, m.collectSupplementalRecordsBySource)
if !rebuilt {
rebuilt := false
mark, published := scope.publish(func() bool {
rebuilt = readStore.TryReplaceRegistryForRead(snapshotForStore, readPathRegistryFreshness, m.collectSupplementalRecordsBySource)
if rebuilt {
recordSupplementalResourceChanges(store, m.collectSupplementalChanges())
}
return rebuilt
})
if !published || !rebuilt {
return
}
recordSupplementalResourceChanges(store, m.collectSupplementalChanges())
m.syncPublishedResourceStore(store, scope, mark)
}
// syncPublishedResourceStore runs the metric and alert syncs over the registry
// a refresh just published under scope. A rebuild from an ended mock-mode
// epoch that overlapped the refresh may have replaced that registry with the
// mode the monitor left, so the alert sync runs only if none did.
func (m *Monitor) syncPublishedResourceStore(store ResourceStoreInterface, scope mockModeScope, mark uint64) {
store = newResourceSnapshotStore(store)
resources := store.GetAll()
m.syncAllUnifiedMetrics(store)
m.syncUnifiedResourceAlertsToState(store.GetAll())
if scope.undisturbedSince(mark) {
m.syncUnifiedResourceAlertsToState(resources, scope)
}
}
// currentStateWithScope reads the monitor state together with the mock-mode
// scope it was read in. GetState serves the fixture graph in mock mode, so a
// refresh that evaluates alerts from it must take the scope first.
func (m *Monitor) currentStateWithScope() (models.StateSnapshot, mockModeScope) {
scope := m.mockModeFence.begin()
return m.GetState(), scope
}
// updateResourceStore populates the canonical resource store from current
// monitoring state. Callers use it at accepted-ingest boundaries and before
// broadcast hydration so every ReadState consumer observes the same snapshot.
func (m *Monitor) updateResourceStore(state models.StateSnapshot) {
// The scope must predate the read of state (see currentStateWithScope).
func (m *Monitor) updateResourceStore(state models.StateSnapshot, scope mockModeScope) {
m.mu.RLock()
store := m.resourceStore
m.mu.RUnlock()
@@ -5263,46 +5324,48 @@ func (m *Monitor) updateResourceStore(state models.StateSnapshot) {
Msg("[Resources] Suppressing legacy snapshot slices for provider-owned sources")
}
recordsBySource := m.collectSupplementalRecordsBySource()
supplementalChanges := m.collectSupplementalChanges()
if atomicStore, ok := store.(AtomicSnapshotResourceStore); ok {
atomicStore.PopulateSnapshotAndSupplemental(snapshotForStore, recordsBySource)
recordSupplementalResourceChanges(store, supplementalChanges)
store = newResourceSnapshotStore(store)
m.syncAllUnifiedMetrics(store)
for source, records := range recordsBySource {
if len(records) == 0 {
continue
mark, published := scope.publish(func() bool {
recordsBySource := m.collectSupplementalRecordsBySource()
supplementalChanges := m.collectSupplementalChanges()
if atomicStore, ok := store.(AtomicSnapshotResourceStore); ok {
atomicStore.PopulateSnapshotAndSupplemental(snapshotForStore, recordsBySource)
recordSupplementalResourceChanges(store, supplementalChanges)
for source, records := range recordsBySource {
if len(records) == 0 {
continue
}
log.Debug().
Str("source", string(source)).
Int("records", len(records)).
Msg("[Resources] Atomically ingested supplemental records")
}
log.Debug().
Str("source", string(source)).
Int("records", len(records)).
Msg("[Resources] Atomically ingested supplemental records")
return true
}
m.syncUnifiedResourceAlertsToState(store.GetAll())
store.PopulateFromSnapshot(snapshotForStore)
supplementalStore, ok := store.(SupplementalRecordStore)
if ok {
for source, records := range recordsBySource {
if len(records) == 0 {
continue
}
supplementalStore.PopulateSupplementalRecords(source, records)
log.Debug().
Str("source", string(source)).
Int("records", len(records)).
Msg("[Resources] Ingested supplemental records")
}
}
recordSupplementalResourceChanges(store, supplementalChanges)
return true
})
if !published {
// The snapshot belongs to the mode the monitor just left.
return
}
store.PopulateFromSnapshot(snapshotForStore)
supplementalStore, ok := store.(SupplementalRecordStore)
if ok {
for source, records := range recordsBySource {
if len(records) == 0 {
continue
}
supplementalStore.PopulateSupplementalRecords(source, records)
log.Debug().
Str("source", string(source)).
Int("records", len(records)).
Msg("[Resources] Ingested supplemental records")
}
}
recordSupplementalResourceChanges(store, supplementalChanges)
store = newResourceSnapshotStore(store)
m.syncAllUnifiedMetrics(store)
m.syncUnifiedResourceAlertsToState(store.GetAll())
m.syncPublishedResourceStore(store, scope, mark)
}
// resourceSnapshotStore serves one GetAll clone to every consumer of a single
@@ -5356,7 +5419,7 @@ func (m *Monitor) refreshUnifiedResourceStoreAfterAgentStateChange() {
if m == nil || m.state == nil {
return
}
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
}
// agentReportRefreshInterval bounds how often accepted agent reports refresh
@@ -5389,7 +5452,7 @@ func (m *Monitor) refreshUnifiedResourceStoreAfterAgentReport() {
}
window := m.agentReportRefreshWindow
if window <= 0 {
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
return
}
@@ -5405,7 +5468,7 @@ func (m *Monitor) refreshUnifiedResourceStoreAfterAgentReport() {
r.running = true
r.lastRun = now
r.mu.Unlock()
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
r.mu.Lock()
r.running = false
r.mu.Unlock()
@@ -5429,7 +5492,7 @@ func (m *Monitor) runTrailingAgentReportRefresh() {
r.lastRun = time.Now()
r.mu.Unlock()
m.updateResourceStore(m.GetState())
m.updateResourceStore(m.currentStateWithScope())
r.mu.Lock()
r.running = false
@@ -210,7 +210,7 @@ func TestSyncUnifiedResourceAlertsPersistsAndEvaluatesTrueNASOverrideSuccession(
trueNASMemoryAlertResource(secondID, "", sharedName, 90),
trueNASMemoryAlertResource(newID, oldID, sharedName, 90),
}
monitor.syncUnifiedResourceAlertsToState(resources)
monitor.syncUnifiedResourceAlertsToState(resources, monitor.mockModeFence.begin())
inMemory := manager.GetConfig()
if _, exists := inMemory.Overrides[oldID]; exists {
@@ -246,7 +246,7 @@ func TestSyncUnifiedResourceAlertsPersistsAndEvaluatesTrueNASOverrideSuccession(
trueNASMemoryAlertResource(newID, oldID, sharedName, 96),
trueNASMemoryAlertResource(secondID, "", sharedName, 70),
}
monitor.syncUnifiedResourceAlertsToState(resources)
monitor.syncUnifiedResourceAlertsToState(resources, monitor.mockModeFence.begin())
warning := waitForAlertNotification(t, notifications, newID, alerts.AlertLevelWarning)
waitForAlertResolution(t, resolutions, secondID)
if warning.Threshold != overridePct {
@@ -254,7 +254,7 @@ func TestSyncUnifiedResourceAlertsPersistsAndEvaluatesTrueNASOverrideSuccession(
}
resources[0] = trueNASMemoryAlertResource(newID, oldID, sharedName, 99)
monitor.syncUnifiedResourceAlertsToState(resources)
monitor.syncUnifiedResourceAlertsToState(resources, monitor.mockModeFence.begin())
critical := waitForAlertNotification(t, notifications, newID, alerts.AlertLevelCritical)
if critical.Threshold != overridePct {
t.Fatalf("critical notification threshold = %.1f, want %.1f", critical.Threshold, overridePct)
@@ -264,7 +264,7 @@ func TestSyncUnifiedResourceAlertsPersistsAndEvaluatesTrueNASOverrideSuccession(
}
resources[0] = trueNASMemoryAlertResource(newID, oldID, sharedName, 89)
monitor.syncUnifiedResourceAlertsToState(resources)
monitor.syncUnifiedResourceAlertsToState(resources, monitor.mockModeFence.begin())
if _, exists := activeMemoryAlertForResource(manager, newID); exists {
t.Fatalf("TrueNAS alert did not clear below the persisted 90%% recovery threshold")
}
@@ -284,7 +284,7 @@ func TestSyncUnifiedResourceAlertsPersistsAndEvaluatesTrueNASOverrideSuccession(
restartedMonitor.syncUnifiedResourceAlertsToState([]unifiedresources.Resource{
trueNASMemoryAlertResource(newID, "", sharedName, 90),
trueNASMemoryAlertResource(secondID, "", sharedName, 70),
})
}, restartedMonitor.mockModeFence.begin())
if _, exists := activeMemoryAlertForResource(restartedManager, newID); exists {
t.Fatalf("restarted manager evaluated %s at the default threshold", newID)
}
@@ -434,7 +434,7 @@ func TestSyncUnifiedResourceAlertsMigratesDockerContainerOverrideKeys(t *testing
monitor.syncUnifiedResourceAlertsToState([]unifiedresources.Resource{
dockerApp("app-container-1111111111111111", "host-1", "aaaaaaaaaaaa", "media-server"),
dockerApp("app-container-0011223344556677", "host-1", "cccccccccccc", "proxy"),
})
}, monitor.mockModeFence.begin())
inMemory := manager.GetConfig()
for _, gone := range []string{
@@ -134,7 +134,7 @@ func TestResourcePublicationReconcilesRestoredNativeAlertAliases(t *testing.T) {
if got := len(manager.GetActiveAlerts()); got != 3 {
t.Fatalf("alerts before resource publication = %d, want 3", got)
}
monitor.updateResourceStore(snapshot)
monitor.updateResourceStore(snapshot, monitor.mockModeFence.begin())
active := manager.GetActiveAlerts()
if len(active) != 1 || active[0].ResourceID != "unaffected" {
t.Fatalf("resource publication did not reconcile restored aliases: %+v", active)
+33 -10
View File
@@ -94,15 +94,39 @@ func (m *Monitor) recentlyResolvedAlertsSnapshot() []models.ResolvedAlert {
return m.alertManager.GetRecentlyResolved()
}
func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.Resource) {
// syncUnifiedResourceAlertsToState evaluates resources read under scope. A
// mock-mode flip since that read refuses every remaining step, so a snapshot
// of the mode the monitor left can neither open or resolve alerts nor rewrite
// persisted identities and links in the mode it entered.
func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.Resource, scope mockModeScope) {
if m == nil || m.alertManager == nil {
return
}
// Resource publication can make persisted canonical policy resolvable for
// restored native-ID alerts that preceded the first observation at startup.
m.alertManager.ReconcileOperatorIntentState()
scope.run(func() { m.alertManager.ReconcileOperatorIntentState() })
scope.run(func() { m.migrateAlertIdentities(resources) })
scope.run(func() { m.migrateAvailabilityLinksToCanonicalIDs(resources) })
// Metrics go one resource per fenced call, as
// CheckUnifiedResourceMetricsWithCapacityTrends would iterate them, so a
// mode change waits for one resource's evaluation rather than the estate's.
capacityTrends := m.unifiedStorageCapacityTrends(resources, time.Now())
for _, resource := range resources {
input, ok := alerts.UnifiedResourceInputFromResource(resource)
if !ok {
continue
}
scope.run(func() { m.alertManager.CheckUnifiedResourceWithCapacityTrend(input, capacityTrends[input.ID]) })
}
scope.run(func() { m.alertManager.SyncUnifiedResourceIncidents(resources) })
m.syncAlertsToState()
}
// migrateAlertIdentities persists the alert identity migration that resources
// make resolvable.
func (m *Monitor) migrateAlertIdentities(resources []unifiedresources.Resource) {
config := m.alertManager.GetConfig()
migrationPlan := alerts.PlanAlertIdentityMigration(config, resources)
if migrationPlan.UnsupportedVersion {
@@ -136,12 +160,6 @@ func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.
Msg("persisted alert identity migration plan")
}
}
m.migrateAvailabilityLinksToCanonicalIDs(resources)
m.alertManager.CheckUnifiedResourceMetricsWithCapacityTrends(resources, m.unifiedStorageCapacityTrends(resources, time.Now()))
m.alertManager.SyncUnifiedResourceIncidents(resources)
m.syncAlertsToState()
}
// pruneStaleDockerAlerts removes docker alerts that reference hosts no longer present in state.
@@ -150,7 +168,10 @@ func (m *Monitor) pruneStaleDockerAlerts() bool {
return false
}
readState := m.GetUnifiedReadStateOrSnapshot()
// The inventory is the fixture read view in mock mode. A prune that read
// it must not remove Docker alerts the other mode raised after a switch.
scope := m.mockModeFence.begin()
readState := m.currentModeReadState()
if readState == nil {
return false
}
@@ -216,7 +237,9 @@ func (m *Monitor) pruneStaleDockerAlerts() bool {
host.Hostname = hostID
}
m.alertManager.HandleDockerHostRemoved(host)
if !scope.run(func() { m.alertManager.HandleDockerHostRemoved(host) }) {
return cleared
}
processed[hostID] = struct{}{}
cleared = true
}
+38 -22
View File
@@ -181,8 +181,10 @@ func (m *Monitor) checkConnectionAlerts() {
if lister == nil || m.alertManager == nil {
return
}
// The lister serves the mock connection ledger while mock mode is on.
scope := m.mockModeFence.begin()
for _, snap := range lister() {
m.alertManager.CheckConnection(snap)
scope.run(func() { m.alertManager.CheckConnection(snap) })
}
}
@@ -628,6 +630,10 @@ func (m *Monitor) broadcastStateUpdate() {
func (m *Monitor) checkMockAlerts() {
defer recoverFromPanic("checkMockAlerts")
// Passes overlap and can still be reading the fixture when the monitor
// leaves mock mode, so every evaluation below runs under the scope taken
// before the mode check and stops once SetMockMode ends its epoch.
scope := m.mockModeFence.begin()
log.Debug().Bool("mockEnabled", mock.IsMockEnabled()).Msg("checkMockAlerts called")
if !mock.IsMockEnabled() {
log.Debug().Msg("mock mode not enabled, skipping mock alert check")
@@ -661,7 +667,7 @@ func (m *Monitor) checkMockAlerts() {
log.Debug().
Int("trackedNodes", len(existingNodes)).
Msg("Collecting resources for alert cleanup in mock mode")
m.alertManager.CleanupAlertsForNodes(existingNodes)
scope.run(func() { m.alertManager.CleanupAlertsForNodes(existingNodes) })
m.checkBackupAlerts(context.Background())
@@ -678,7 +684,7 @@ func (m *Monitor) checkMockAlerts() {
Msg("Reached guest check limit for this cycle")
break
}
m.alertManager.CheckGuest(vm, "mock")
scope.run(func() { m.alertManager.CheckGuest(vm, "mock") })
guestsChecked++
}
@@ -687,7 +693,7 @@ func (m *Monitor) checkMockAlerts() {
if guestsChecked >= maxGuestsPerCycle {
break
}
m.alertManager.CheckGuest(container, "mock")
scope.run(func() { m.alertManager.CheckGuest(container, "mock") })
guestsChecked++
}
@@ -699,11 +705,11 @@ func (m *Monitor) checkMockAlerts() {
// and disk alerts to its agent on the first tick, as the host-agent
// hostname deduplication does in production.
log.Debug().Int("hostCount", len(state.Hosts)).Msg("checking host agent alerts")
m.evaluateMockHostAgents(state.Hosts, state.Nodes)
m.evaluateMockHostAgents(scope, state.Hosts, state.Nodes)
// Check alerts for each node
for _, node := range state.Nodes {
m.alertManager.CheckNode(node)
scope.run(func() { m.alertManager.CheckNode(node) })
}
// Check alerts for storage
@@ -713,7 +719,8 @@ func (m *Monitor) checkMockAlerts() {
Str("name", storage.Name).
Float64("usage", storage.Usage).
Msg("Checking storage for alerts")
m.alertManager.CheckStorageWithCapacityTrend(storage, m.storageCapacityTrend(storage, time.Now()))
trend := m.storageCapacityTrend(storage, time.Now())
scope.run(func() { m.alertManager.CheckStorageWithCapacityTrend(storage, trend) })
}
// Check alerts for physical disks. Live disks are evaluated by the
@@ -722,18 +729,18 @@ func (m *Monitor) checkMockAlerts() {
// this pass the estate shows failing disks with no disk-health or
// disk-wearout alert.
log.Debug().Int("diskCount", len(state.PhysicalDisks)).Msg("checking physical disk alerts")
m.checkMockPhysicalDiskAlerts(state.PhysicalDisks, state.Nodes, state.Hosts)
m.checkMockPhysicalDiskAlerts(scope, state.PhysicalDisks, state.Nodes, state.Hosts)
// Check alerts for PBS instances
log.Debug().Int("pbsCount", len(state.PBSInstances)).Msg("checking PBS alerts")
for _, pbsInst := range state.PBSInstances {
m.alertManager.CheckPBS(pbsInst)
scope.run(func() { m.alertManager.CheckPBS(pbsInst) })
}
// Check alerts for PMG instances
log.Debug().Int("pmgCount", len(state.PMGInstances)).Msg("checking PMG alerts")
for _, pmgInst := range state.PMGInstances {
m.alertManager.CheckPMG(pmgInst)
scope.run(func() { m.alertManager.CheckPMG(pmgInst) })
}
// Check alerts for Docker hosts (container state/health/metrics/updates and
@@ -742,25 +749,29 @@ func (m *Monitor) checkMockAlerts() {
// against mock data.
log.Debug().Int("dockerHostCount", len(state.DockerHosts)).Msg("checking docker alerts")
for _, dockerHost := range state.DockerHosts {
m.checkMockDockerHostAlerts(dockerHost)
scope.run(func() { m.checkMockDockerHostAlerts(dockerHost) })
}
// Cache the latest alert snapshots directly in the mock data so the API can serve
// mock state without needing to grab the alert manager lock again.
mock.UpdateAlertSnapshots(m.alertManager.GetActiveAlerts(), m.alertManager.GetRecentlyResolved())
scope.run(func() {
mock.UpdateAlertSnapshots(m.alertManager.GetActiveAlerts(), m.alertManager.GetRecentlyResolved())
})
}
// evaluateMockHostAgents evaluates every fixture agent and remembers the set.
// A runtime mock config change rebuilds the estate, so an agent can leave it
// between passes; it then goes through HandleHostRemoved, as a deleted live
// agent does, or its alerts and hostname deduplication would outlive it.
func (m *Monitor) evaluateMockHostAgents(hosts []models.Host, nodes []models.Node) {
func (m *Monitor) evaluateMockHostAgents(scope mockModeScope, hosts []models.Host, nodes []models.Node) {
m.mockHostAgentsMu.Lock()
defer m.mockHostAgentsMu.Unlock()
// A pass that took its snapshot before the monitor left mock mode must
// not re-register fixture agents after forgetMockHostAgents ran.
if !mock.IsMockEnabled() {
// SetMockMode ends the epoch before it forgets them, so either this check
// fails or forgetMockHostAgents waits for this pass to record its set.
if !scope.current() {
return
}
@@ -773,12 +784,17 @@ func (m *Monitor) evaluateMockHostAgents(hosts []models.Host, nodes []models.Nod
// Remove first so a departed agent cannot unregister a hostname that a
// remaining agent registers on this same pass.
for id, host := range m.mockHostAgents {
if _, ok := current[id]; !ok {
m.alertManager.HandleHostRemoved(host)
if _, ok := current[id]; ok {
continue
}
if !scope.run(func() { m.alertManager.HandleHostRemoved(host) }) {
// Leaving mock mode refused the removal; keep the agent so
// forgetMockHostAgents still removes it.
current[id] = host
}
}
for _, host := range hosts {
m.checkMockHostAlerts(host, nodes)
scope.run(func() { m.checkMockHostAlerts(host, nodes) })
}
m.mockHostAgents = current
}
@@ -786,9 +802,9 @@ func (m *Monitor) evaluateMockHostAgents(hosts []models.Host, nodes []models.Nod
// forgetMockHostAgents removes the fixture agents when the monitor leaves mock
// mode. ClearActiveAlerts drops their alerts but not their hostname
// deduplication, so a real node named like a fixture agent (pve1) would keep
// its CPU, memory and disk alerts suppressed with no agent to own them. The
// removal also clears anything a pass already in flight recreated; mock mode
// is off before this runs, so no later pass can register them again.
// its CPU, memory and disk alerts suppressed with no agent to own them.
// SetMockMode ends the mock epoch before this runs, so the set it removes is
// final: no pass of that epoch can register an agent or evaluate one again.
func (m *Monitor) forgetMockHostAgents() {
m.mockHostAgentsMu.Lock()
defer m.mockHostAgentsMu.Unlock()
@@ -831,7 +847,7 @@ func (m *Monitor) checkMockDockerHostAlerts(host models.DockerHost) {
// evaluated as healthy. The poller's wait for host-agent links to settle after
// a restart does not apply: fixture links and exclusions are complete from the
// first pass.
func (m *Monitor) checkMockPhysicalDiskAlerts(disks []models.PhysicalDisk, nodes []models.Node, hosts []models.Host) {
func (m *Monitor) checkMockPhysicalDiskAlerts(scope mockModeScope, disks []models.PhysicalDisk, nodes []models.Node, hosts []models.Host) {
type nodeKey struct{ instance, name string }
excludeByHost := make(map[string][]string, len(hosts))
@@ -855,6 +871,6 @@ func (m *Monitor) checkMockPhysicalDiskAlerts(disks []models.PhysicalDisk, nodes
if !onlineNodes[key] {
continue
}
m.checkPhysicalDiskAlerts(disk.Instance, disk, excludeByNode[key])
scope.run(func() { m.checkPhysicalDiskAlerts(disk.Instance, disk, excludeByNode[key]) })
}
}
+9 -5
View File
@@ -568,25 +568,27 @@ func (m *Monitor) syncGuestBackupTimesAndResourceStore() {
}
m.state.SyncGuestBackupTimes()
m.updateResourceStore(m.state.GetSnapshot())
scope := m.mockModeFence.begin()
m.updateResourceStore(m.state.GetSnapshot(), scope)
}
func (m *Monitor) backupReadStateForInstance(instanceName string) unifiedresources.ReadState {
if m == nil {
return nil
}
readState := m.GetUnifiedReadStateOrSnapshot()
readState := m.currentModeReadState()
if backupReadStateHasGuestForInstance(readState, instanceName) || m.state == nil {
return readState
}
scope := m.mockModeFence.begin()
snapshot := m.state.GetSnapshot()
if !backupSnapshotHasGuestForInstance(snapshot, instanceName) {
return readState
}
m.updateResourceStore(snapshot)
readState = m.GetUnifiedReadStateOrSnapshot()
m.updateResourceStore(snapshot, scope)
readState = m.currentModeReadState()
if backupReadStateHasGuestForInstance(readState, instanceName) {
return readState
}
@@ -950,6 +952,8 @@ func (m *Monitor) pollPVEBackupsAndSnapshots(parentCtx context.Context, instance
func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, client PVEClientInterface) {
log.Debug().Str("instance", instanceName).Msg("polling guest snapshots")
// The guest inventory comes from the fixture read view in mock mode.
scope := m.mockModeFence.begin()
readState := m.backupReadStateForInstance(instanceName)
var vms []models.VM
for _, vm := range readState.VMs() {
@@ -1270,7 +1274,7 @@ func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, c
}
if m.alertManager != nil {
m.alertManager.CheckSnapshotsForInstance(instanceName, allSnapshots, guestLookups)
scope.run(func() { m.alertManager.CheckSnapshotsForInstance(instanceName, allSnapshots, guestLookups) })
}
log.Debug().
@@ -434,7 +434,7 @@ func TestMonitor_ResourceUpdate(t *testing.T) {
}
// updateResourceStore
m.updateResourceStore(models.StateSnapshot{})
m.updateResourceStore(models.StateSnapshot{}, m.mockModeFence.begin())
// PopulateFromSnapshot called (no-op in mock, but covered)
// getResourcesForBroadcast
@@ -823,9 +823,9 @@ func TestMockHostAgentLeavingFixtureUsesRemovalLifecycle(t *testing.T) {
kept := models.Host{ID: "host-linux-1", Hostname: "apollo-114", Status: "online", Memory: memory}
departed := models.Host{ID: "host-node-pve9", Hostname: "pve9", Status: "online", Memory: memory}
monitor.evaluateMockHostAgents([]models.Host{kept, departed}, nil)
monitor.evaluateMockHostAgents(monitor.mockModeFence.begin(), []models.Host{kept, departed}, nil)
// A runtime mock config change rebuilt the estate without pve9.
monitor.evaluateMockHostAgents([]models.Host{kept}, nil)
monitor.evaluateMockHostAgents(monitor.mockModeFence.begin(), []models.Host{kept}, nil)
keptAlert := false
for _, alert := range manager.GetActiveAlerts() {
+575 -6
View File
@@ -1,13 +1,23 @@
package monitoring
import (
"bytes"
"context"
"fmt"
"runtime"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestCheckMockDockerHostAlertsUsesHostLifecycleForOfflineFixtures(t *testing.T) {
@@ -209,21 +219,25 @@ func TestLeavingMockModeReleasesFixtureAgentHostnames(t *testing.T) {
func TestMockHostAgentPassAfterLeavingMockModeRegistersNothing(t *testing.T) {
manager := newMockHostAlertTestManager(t)
monitor := &Monitor{alertManager: manager}
mustSetMockEnabled(t, false)
// A pass whose snapshot predates leaving mock mode reaches the agent step
// after forgetMockHostAgents ran.
// after SetMockMode ended its epoch and forgetMockHostAgents ran.
scope := monitor.mockModeFence.begin()
monitor.mockModeFence.advance()
stale := models.Host{
ID: "host-node-mock-cluster-1-pve1",
Hostname: "pve1",
Status: "online",
Memory: models.Memory{Total: 32 << 30, Used: 30 << 30, Free: 2 << 30, Usage: 93.75},
}
monitor.evaluateMockHostAgents([]models.Host{stale}, nil)
monitor.evaluateMockHostAgents(scope, []models.Host{stale}, nil)
if active := manager.GetActiveAlerts(); len(active) != 0 {
t.Fatalf("stale mock pass opened %d alerts in live mode", len(active))
}
if len(monitor.mockHostAgents) != 0 {
t.Fatalf("stale mock pass registered fixture agents %v after they were forgotten", monitor.mockHostAgents)
}
node := models.Node{
ID: "real-pve1",
Name: "pve1",
@@ -240,6 +254,561 @@ func TestMockHostAgentPassAfterLeavingMockModeRegistersNothing(t *testing.T) {
t.Fatal("stale mock pass registered fixture hostname pve1 and suppressed the real node's memory alert")
}
// pinDefaultMockEstate enables mock mode on the default estate, whatever
// PULSE_MOCK_* the shell exports, and restores the previous mode and config.
func pinDefaultMockEstate(t *testing.T) {
t.Helper()
previousEnabled, previousConfig := mock.IsMockEnabled(), mock.GetConfig()
t.Cleanup(func() {
mustSetMockEnabled(t, false)
mock.SetMockConfig(previousConfig)
mustSetMockEnabled(t, previousEnabled)
})
mustSetMockEnabled(t, false)
cfg := mock.DefaultConfig
cfg.UpdateInterval = 5 * time.Minute
mock.SetMockConfig(cfg)
}
// signalFirstFiring closes the returned channel when manager fires its first
// alert. Lifecycle callbacks run inside the evaluation that fired, so the
// pass that fired is past its fixture read when the channel closes.
func signalFirstFiring(t *testing.T, manager *alerts.Manager) <-chan struct{} {
t.Helper()
fired := make(chan struct{})
var once sync.Once
unsubscribe := manager.SubscribeLifecycleCallback(func(event alerts.LifecycleEvent) {
if event.Type == eventlog.TypeFired {
once.Do(func() { close(fired) })
}
})
t.Cleanup(unsubscribe)
return fired
}
func describeAlerts(active []alerts.Alert) string {
keys := make([]string, 0, len(active))
for _, alert := range active {
keys = append(keys, alert.Type+" "+alert.ResourceID)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// switchMockModeAsync runs SetMockMode on its own goroutine, so a test that is
// holding an evaluation parked can still bound how long the switch takes.
func switchMockModeAsync(t *testing.T, monitor *Monitor, enable bool) <-chan struct{} {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
if err := monitor.SetMockMode(enable); err != nil {
t.Errorf("SetMockMode(%v): %v", enable, err)
}
}()
return done
}
// goroutineIn reports whether one goroutine's stack holds every frame.
func goroutineIn(frames ...string) bool {
buf := make([]byte, 4<<20)
n := runtime.Stack(buf, true)
for _, goroutine := range bytes.Split(buf[:n], []byte("\n\n")) {
all := true
for _, frame := range frames {
all = all && bytes.Contains(goroutine, []byte(frame))
}
if all {
return true
}
}
return false
}
// waitForGoroutineIn waits until one goroutine's stack holds every frame. A
// test holding a lock uses it to see the goroutine it started parked on that
// lock at a known point.
func waitForGoroutineIn(t *testing.T, frames ...string) {
t.Helper()
waitForCondition(t, 30*time.Second, func() bool { return goroutineIn(frames...) },
fmt.Sprintf("no goroutine reached %s", strings.Join(frames, " via ")))
}
func TestLeavingMockModeRefusesTheRestOfAnInFlightPass(t *testing.T) {
setMockSamplerTestEnv(t, time.Hour, 5*time.Minute)
pinDefaultMockEstate(t)
manager := newMockHostAlertTestManager(t)
monitor := &Monitor{
state: models.NewState(),
alertManager: manager,
metricsHistory: NewMetricsHistory(10, time.Hour),
}
mustSetMonitorMockMode(t, monitor, true)
// Park a real pass at its first storage capacity read: it has read the
// fixture and evaluated guests, agents and nodes; storage, disks, PBS,
// PMG and Docker are still ahead of it.
monitor.metricsHistory.capacityForecastMu.Lock()
parked := true
defer func() {
if parked {
monitor.metricsHistory.capacityForecastMu.Unlock()
}
}()
passDone := make(chan struct{})
go func() {
defer close(passDone)
monitor.checkMockAlerts()
}()
waitForGoroutineIn(t, "monitoring.(*Monitor).storageCapacityTrendFor", "monitoring.(*Monitor).checkMockAlerts")
waitForSignal(t, switchMockModeAsync(t, monitor, false), 30*time.Second, "leaving mock mode waited on a pass parked outside any evaluation")
monitor.metricsHistory.capacityForecastMu.Unlock()
parked = false
waitForSignal(t, passDone, 30*time.Second, "mock pass did not finish after it was released")
if active := manager.GetActiveAlerts(); len(active) != 0 {
t.Fatalf("a mock pass in flight when mock mode ended left %d fixture alerts in live mode: %s",
len(active), describeAlerts(active))
}
}
func TestLeavingMockModeWaitsForAnInFlightEvaluation(t *testing.T) {
setMockSamplerTestEnv(t, time.Hour, 5*time.Minute)
pinDefaultMockEstate(t)
manager := newMockHostAlertTestManager(t)
monitor := &Monitor{
state: models.NewState(),
alertManager: manager,
metricsHistory: NewMetricsHistory(10, time.Hour),
}
mustSetMonitorMockMode(t, monitor, true)
// Park a real pass inside its first guest evaluation, in the CPU window
// read the alert manager makes outside its own lock, so nothing but the
// fence holds the switch back.
entered, release := make(chan struct{}), make(chan struct{})
var once sync.Once
manager.SetMetricWindowProvider(func(alerts.MetricWindowRequest) ([]alerts.MetricWindowPoint, error) {
once.Do(func() {
close(entered)
<-release
})
return nil, nil
})
released := false
defer func() {
if !released {
close(release)
}
}()
passDone := make(chan struct{})
go func() {
defer close(passDone)
monitor.checkMockAlerts()
}()
waitForSignal(t, entered, 30*time.Second, "mock pass never evaluated a windowed metric")
// The switch must stop in advance until the evaluation returns. One
// that finishes first cleared alerts the evaluation can still raise.
leftMockMode := switchMockModeAsync(t, monitor, false)
waitForCondition(t, 30*time.Second, func() bool {
select {
case <-leftMockMode:
return true
default:
}
return goroutineIn("monitoring.(*mockModeFence).advance", "monitoring.(*Monitor).SetMockMode")
}, "leaving mock mode neither finished nor waited for the running evaluation")
select {
case <-leftMockMode:
t.Fatal("leaving mock mode cleared alerts while a fixture evaluation was still running")
default:
}
close(release)
released = true
waitForSignal(t, leftMockMode, 30*time.Second, "leaving mock mode did not finish once the evaluation returned")
waitForSignal(t, passDone, 30*time.Second, "mock pass did not finish after it was released")
if active := manager.GetActiveAlerts(); len(active) != 0 {
t.Fatalf("the evaluation running when mock mode ended left %d fixture alerts in live mode: %s",
len(active), describeAlerts(active))
}
}
// blockingFixtureRecords serves one source's fixture records the way the
// router's mock adapter does, reading them on each call, and holds its first
// call until released. Later calls are not held, not even while the first is.
type blockingFixtureRecords struct {
source unifiedresources.DataSource
entered chan struct{}
release chan struct{}
held atomic.Bool
}
func newBlockingFixtureRecords(source unifiedresources.DataSource) *blockingFixtureRecords {
return &blockingFixtureRecords{source: source, entered: make(chan struct{}), release: make(chan struct{})}
}
func (p *blockingFixtureRecords) SupplementalRecords(_ *Monitor, _ string) []unifiedresources.IngestRecord {
records := mock.SupplementalRecords(p.source)
if p.held.CompareAndSwap(false, true) {
close(p.entered)
<-p.release
}
return records
}
func TestLeavingMockModeDiscardsAnInFlightFrontendRefresh(t *testing.T) {
setMockSamplerTestEnv(t, time.Hour, 5*time.Minute)
pinDefaultMockEstate(t)
manager := newMockHostAlertTestManager(t)
provider := newBlockingFixtureRecords(unifiedresources.SourceVMware)
monitor := &Monitor{
state: models.NewState(),
alertManager: manager,
metricsHistory: NewMetricsHistory(10, time.Hour),
resourceStore: unifiedresources.NewMonitorAdapter(nil),
supplementalProviders: map[unifiedresources.DataSource]MonitorSupplementalRecordsProvider{
unifiedresources.SourceVMware: provider,
},
}
mustSetMonitorMockMode(t, monitor, true)
// Hold a frontend read in its registry rebuild: it has the fixture
// snapshot and the vSphere fixture records, whose datastore, host and
// network incidents its unified alert sync would raise.
released := false
defer func() {
if !released {
close(provider.release)
}
}()
readDone := make(chan struct{})
go func() {
defer close(readDone)
monitor.BuildFrontendState()
}()
waitForSignal(t, provider.entered, 30*time.Second, "frontend read never reached the supplemental records")
waitForSignal(t, switchMockModeAsync(t, monitor, false), 30*time.Second, "leaving mock mode waited on a registry rebuild")
close(provider.release)
released = true
waitForSignal(t, readDone, 30*time.Second, "frontend read did not finish after it was released")
if active := manager.GetActiveAlerts(); len(active) != 0 {
t.Fatalf("a frontend read in flight when mock mode ended left %d fixture alerts in live mode: %s",
len(active), describeAlerts(active))
}
}
// gatedListStore holds the first registry listing made after open is called,
// so a test can publish another generation between a refresh's own publish
// and its read back.
type gatedListStore struct {
*unifiedresources.MonitorAdapter
mu sync.Mutex
armed bool
entered chan struct{}
release chan struct{}
}
func (s *gatedListStore) arm() {
s.mu.Lock()
defer s.mu.Unlock()
s.armed = true
}
func (s *gatedListStore) GetAll() []unifiedresources.Resource {
s.mu.Lock()
hold := s.armed
s.armed = false
s.mu.Unlock()
if hold {
close(s.entered)
<-s.release
}
return s.MonitorAdapter.GetAll()
}
// fixedRecords stands in for a live provider: it serves the same records in
// either mode.
type fixedRecords []unifiedresources.IngestRecord
func (r fixedRecords) SupplementalRecords(_ *Monitor, _ string) []unifiedresources.IngestRecord {
return r
}
func alertTypes(active []alerts.Alert) map[string]int {
types := make(map[string]int)
for _, alert := range active {
types[alert.Type]++
}
return types
}
func TestLiveRefreshIgnoresARegistryRepublishedFromMockMode(t *testing.T) {
setMockSamplerTestEnv(t, time.Hour, 5*time.Minute)
pinDefaultMockEstate(t)
manager := newMockHostAlertTestManager(t)
mustSetMockEnabled(t, true)
// TrueNAS records stand in for a live provider's data; the vSphere
// fixture source serves nothing once mock mode is off.
liveTrueNAS := fixedRecords(mock.SupplementalRecords(unifiedresources.SourceTrueNAS))
mustSetMockEnabled(t, false)
if len(liveTrueNAS) == 0 {
t.Fatal("mock fixture has no TrueNAS records")
}
provider := newBlockingFixtureRecords(unifiedresources.SourceVMware)
store := &gatedListStore{
MonitorAdapter: unifiedresources.NewMonitorAdapter(nil),
entered: make(chan struct{}),
release: make(chan struct{}),
}
monitor := &Monitor{
state: models.NewState(),
alertManager: manager,
metricsHistory: NewMetricsHistory(10, time.Hour),
resourceStore: store,
supplementalProviders: map[unifiedresources.DataSource]MonitorSupplementalRecordsProvider{
unifiedresources.SourceVMware: provider,
unifiedresources.SourceTrueNAS: liveTrueNAS,
},
}
mustSetMonitorMockMode(t, monitor, true)
releasedProvider, releasedList := false, false
defer func() {
if !releasedProvider {
close(provider.release)
}
if !releasedList {
close(store.release)
}
}()
// A mock-mode refresh holds the fixture snapshot and vSphere records and
// has not published them yet.
staleDone := make(chan struct{})
go func() {
defer close(staleDone)
monitor.updateResourceStore(monitor.currentStateWithScope())
}()
waitForSignal(t, provider.entered, 30*time.Second, "mock-mode refresh never reached the supplemental records")
waitForSignal(t, switchMockModeAsync(t, monitor, false), 30*time.Second, "leaving mock mode waited on a registry rebuild")
// A live refresh publishes the live TrueNAS records alone and is held
// before reading the registry back.
store.arm()
liveDone := make(chan struct{})
go func() {
defer close(liveDone)
monitor.updateResourceStore(monitor.currentStateWithScope())
}()
waitForSignal(t, store.entered, 30*time.Second, "live refresh never read the registry back")
for _, resource := range store.MonitorAdapter.GetAll() {
if slices.Contains(resource.Sources, unifiedresources.SourceVMware) {
t.Fatalf("the live refresh published vSphere fixture resource %s", resource.ID)
}
}
// The mock-mode refresh now publishes the fixture estate over it.
close(provider.release)
releasedProvider = true
waitForSignal(t, staleDone, 30*time.Second, "mock-mode refresh did not finish after it was released")
close(store.release)
releasedList = true
waitForSignal(t, liveDone, 30*time.Second, "live refresh did not finish after it was released")
if active := manager.GetActiveAlerts(); len(active) != 0 {
t.Fatalf("a live refresh evaluated a registry the mock-mode refresh republished, leaving %d fixture alerts: %s",
len(active), describeAlerts(active))
}
if monitor.mockModeFence.registryCurrent() {
t.Fatal("the registry the mock-mode refresh republished counted as current")
}
// The next live refresh is undisturbed: it replaces the registry and
// evaluates the live records.
monitor.updateResourceStore(monitor.currentStateWithScope())
if !monitor.mockModeFence.registryCurrent() {
t.Fatal("an undisturbed live rebuild did not make the registry current")
}
types := alertTypes(manager.GetActiveAlerts())
if types["zfs-pool-state"] == 0 {
t.Fatalf("the undisturbed live refresh raised no alert from the live TrueNAS records: %v", types)
}
for _, fixtureType := range []string{"resource-incident", "storage-incident"} {
if types[fixtureType] != 0 {
t.Fatalf("vSphere fixture incidents reached live mode: %v", types)
}
}
}
func TestDockerPruneIgnoresTheRegistryMockModeLeft(t *testing.T) {
setMockSamplerTestEnv(t, time.Hour, 5*time.Minute)
pinDefaultMockEstate(t)
manager := newMockHostAlertTestManager(t)
monitor := &Monitor{
state: models.NewState(),
alertManager: manager,
metricsHistory: NewMetricsHistory(10, time.Hour),
resourceStore: unifiedresources.NewMonitorAdapter(nil),
}
mustSetMonitorMockMode(t, monitor, true)
monitor.updateResourceStore(monitor.currentStateWithScope())
mustSetMonitorMockMode(t, monitor, false)
// Before any live rebuild replaces the fixture registry, a live Docker
// host reports and then goes offline.
host := models.DockerHost{ID: "live-docker-1", Hostname: "live-docker-1", DisplayName: "live-docker-1", Status: "offline"}
monitor.state.UpsertDockerHost(host)
for range 3 {
manager.HandleDockerHostOffline(host)
}
if types := alertTypes(manager.GetActiveAlerts()); types["docker-host-offline"] != 1 {
t.Fatalf("live Docker host raised no offline alert: %v", types)
}
monitor.SyncAlertState()
if types := alertTypes(manager.GetActiveAlerts()); types["docker-host-offline"] != 1 {
t.Fatalf("Docker pruning removed a live host's alert against the fixture registry: %v", types)
}
}
func TestPreviousStateCarryIgnoresTheRegistryMockModeLeft(t *testing.T) {
setMockSamplerTestEnv(t, time.Hour, 5*time.Minute)
pinDefaultMockEstate(t)
monitor := &Monitor{
state: models.NewState(),
alertManager: newMockHostAlertTestManager(t),
metricsHistory: NewMetricsHistory(10, time.Hour),
resourceStore: unifiedresources.NewMonitorAdapter(nil),
}
mustSetMonitorMockMode(t, monitor, true)
monitor.updateResourceStore(monitor.currentStateWithScope())
fixture := mock.CurrentFixtureGraph().State
if len(fixture.Nodes) == 0 || len(fixture.VMs) == 0 {
t.Fatal("mock fixture has no nodes or VMs")
}
instance := fixture.VMs[0].Instance
mustSetMonitorMockMode(t, monitor, false)
// A live instance named like a fixture one polls before any live rebuild
// replaces the fixture registry. Its previous-state carry, which writes
// back into live state, must not see the fixture nodes and guests.
if nodes := monitor.previousNodesForInstance(instance); len(nodes) != 0 {
t.Fatalf("previous-node carry for %q returned %d fixture nodes after leaving mock mode", instance, len(nodes))
}
if guests := monitor.previousGuestContextForInstance(instance); len(guests.vms) != 0 || len(guests.containers) != 0 {
t.Fatalf("previous-guest carry for %q returned %d fixture VMs and %d containers after leaving mock mode",
instance, len(guests.vms), len(guests.containers))
}
}
func TestLeavingMockModeDiscardsAnInFlightBackupEvaluation(t *testing.T) {
setMockSamplerTestEnv(t, time.Hour, 5*time.Minute)
pinDefaultMockEstate(t)
manager := newMockHostAlertTestManager(t)
config := manager.GetConfig()
config.BackupDefaults.Enabled = true
config.BackupDefaults.WarningDays = 1
config.BackupDefaults.CriticalDays = 2
manager.UpdateConfig(config)
monitor := &Monitor{
state: models.NewState(),
alertManager: manager,
metricsHistory: NewMetricsHistory(10, time.Hour),
}
mustSetMonitorMockMode(t, monitor, true)
// Cache the fixture read view the guest lookup will be served from.
monitor.GetUnifiedReadStateOrSnapshot()
// Park a real evaluation at its guest lookup, after it read the fixture
// recovery rollups whose stale backups raise backup-age alerts.
monitor.mockUnifiedViewMu.Lock()
parked := true
defer func() {
if parked {
monitor.mockUnifiedViewMu.Unlock()
}
}()
evalDone := make(chan struct{})
go func() {
defer close(evalDone)
monitor.checkBackupAlerts(context.Background())
}()
waitForGoroutineIn(t, "monitoring.(*Monitor).currentUnifiedStateView", "monitoring.(*Monitor).checkBackupAlerts")
waitForSignal(t, switchMockModeAsync(t, monitor, false), 30*time.Second, "leaving mock mode waited on a backup evaluation parked outside the alert manager")
monitor.mockUnifiedViewMu.Unlock()
parked = false
waitForSignal(t, evalDone, 30*time.Second, "backup evaluation did not finish after it was released")
if active := manager.GetActiveAlerts(); len(active) != 0 {
t.Fatalf("a backup evaluation in flight when mock mode ended left %d fixture alerts in live mode: %.500s",
len(active), describeAlerts(active))
}
}
func TestLeavingMockModeDiscardsAnInFlightConnectionCheck(t *testing.T) {
pinDefaultMockEstate(t)
manager := newMockHostAlertTestManager(t)
monitor := &Monitor{
state: models.NewState(),
alertManager: manager,
metricsHistory: NewMetricsHistory(10, time.Hour),
}
mustSetMonitorMockMode(t, monitor, true)
// A hybrid mock ledger lists real connections beside fixture ones, so the
// same connection can be read degraded in mock mode and again live.
degraded := alerts.ConnectionSnapshot{
ID: "pve-cluster-a",
Name: "cluster-a",
Type: alerts.ConnectionTypePVE,
State: alerts.ConnectionStateUnreachable,
Enabled: true,
}
entered, release := make(chan struct{}), make(chan struct{})
var once sync.Once
monitor.SetConnectionsSnapshotLister(func() []alerts.ConnectionSnapshot {
once.Do(func() {
close(entered)
<-release
})
return []alerts.ConnectionSnapshot{degraded}
})
released := false
defer func() {
if !released {
close(release)
}
}()
checkDone := make(chan struct{})
go func() {
defer close(checkDone)
monitor.checkConnectionAlerts()
}()
waitForSignal(t, entered, 30*time.Second, "connection check never read the ledger")
waitForSignal(t, switchMockModeAsync(t, monitor, false), 30*time.Second, "leaving mock mode waited on a connection check parked in its ledger read")
close(release)
released = true
waitForSignal(t, checkDone, 30*time.Second, "connection check did not finish after it was released")
// The connection-degraded alert confirms on its third consecutive
// observation; the one read in mock mode must not be one of them.
monitor.checkConnectionAlerts()
monitor.checkConnectionAlerts()
if active := manager.GetActiveAlerts(); len(active) != 0 {
t.Fatalf("a connection observation read in mock mode counted toward live confirmation: %s", describeAlerts(active))
}
monitor.checkConnectionAlerts()
if active := manager.GetActiveAlerts(); len(active) != 1 {
t.Fatalf("three live degraded observations raised %d alerts, want 1: %s", len(active), describeAlerts(active))
}
}
// diskAlertKeys lists the active disk-health and disk-wearout alerts as
// "type instance/node device" so assertions name the disk, not the alert ID.
func diskAlertKeys(manager *alerts.Manager) map[string]bool {
@@ -331,7 +900,7 @@ func TestCheckMockPhysicalDiskAlertsFollowsPollerBoundary(t *testing.T) {
{Instance: "east", Node: "pve1", DevPath: "/dev/sdb", Model: "WD Red Pro 8TB", Type: "sata", Health: "FAILED", Wearout: -1},
}
monitor.checkMockPhysicalDiskAlerts(disks, nodes, hosts)
monitor.checkMockPhysicalDiskAlerts(monitor.mockModeFence.begin(), disks, nodes, hosts)
got := diskAlertKeys(manager)
if len(got) != 2 || !got["disk-health west/pve1 /dev/sda"] || !got["disk-wearout west/pve1 /dev/nvme0n1"] {
t.Fatalf("disk alerts = %v, want only FAILED health on west/pve1 /dev/sda and wearout on west/pve1 /dev/nvme0n1", got)
@@ -342,7 +911,7 @@ func TestCheckMockPhysicalDiskAlertsFollowsPollerBoundary(t *testing.T) {
// recovery needs three healthy-looking passes.
hosts[0].DiskExclude = []string{"/dev/sda", "nvme0n1"}
for i := 0; i < 3; i++ {
monitor.checkMockPhysicalDiskAlerts(disks, nodes, hosts)
monitor.checkMockPhysicalDiskAlerts(monitor.mockModeFence.begin(), disks, nodes, hosts)
}
if got := diskAlertKeys(manager); len(got) != 0 {
t.Fatalf("excluded devices kept disk alerts %v", got)
@@ -351,7 +920,7 @@ func TestCheckMockPhysicalDiskAlertsFollowsPollerBoundary(t *testing.T) {
// A node that leaves the estate takes its disk alerts with it through the
// node cleanup the mock pass runs first.
hosts[0].DiskExclude = nil
monitor.checkMockPhysicalDiskAlerts(disks, nodes, hosts)
monitor.checkMockPhysicalDiskAlerts(monitor.mockModeFence.begin(), disks, nodes, hosts)
if got := diskAlertKeys(manager); len(got) != 2 {
t.Fatalf("disk alerts after removing the exclusion = %v, want both west/pve1 alerts back", got)
}
@@ -694,7 +694,7 @@ func TestPBSPolledCapacityRequiresObservedRecovery(t *testing.T) {
for range 5 {
monitor.pollPBSInstance(context.Background(), instance.Name, client)
adapter.PopulateFromSnapshot(monitor.state.GetSnapshot())
monitor.syncUnifiedResourceAlertsToState(adapter.GetAll())
monitor.syncUnifiedResourceAlertsToState(adapter.GetAll(), monitor.mockModeFence.begin())
}
}
// Exercise the production poll-to-storage conversion, not just the
+12 -12
View File
@@ -351,7 +351,7 @@ func TestUpdateResourceStoreSyncsUnifiedIncidentAlerts(t *testing.T) {
},
}
monitor.updateResourceStore(models.StateSnapshot{})
monitor.updateResourceStore(models.StateSnapshot{}, monitor.mockModeFence.begin())
active := alertManager.GetActiveAlerts()
if len(active) != 1 {
@@ -412,7 +412,7 @@ func TestUpdateResourceStoreSyncsCanonicalStorageMetrics(t *testing.T) {
},
}
monitor.updateResourceStore(models.StateSnapshot{})
monitor.updateResourceStore(models.StateSnapshot{}, monitor.mockModeFence.begin())
memory := monitor.GetStorageMetrics("pool:tank", time.Hour)
if got := len(memory["usage"]); got == 0 {
@@ -459,7 +459,7 @@ func TestUpdateResourceStoreSyncsCanonicalStorageMetrics(t *testing.T) {
metricsStore: store,
}
monitor.updateResourceStore(snapshot)
monitor.updateResourceStore(snapshot, monitor.mockModeFence.begin())
var storageResourceID string
for _, resource := range resourceStore.GetAll() {
@@ -524,7 +524,7 @@ func TestBuildBroadcastFrontendStateIncludesUnifiedIncidentAlerts(t *testing.T)
},
}
frontend := monitor.buildBroadcastFrontendStateFromSnapshot(models.StateSnapshot{})
frontend := monitor.buildBroadcastFrontendStateFromSnapshot(models.StateSnapshot{}, monitor.mockModeFence.begin())
if len(frontend.ActiveAlerts) != 1 {
t.Fatalf("expected 1 active alert in frontend state, got %d", len(frontend.ActiveAlerts))
@@ -1270,7 +1270,7 @@ func TestBuildBroadcastFrontendStatePrefersLiveAlertManagerOverSnapshotAlerts(t
}},
}
frontend := monitor.buildBroadcastFrontendStateFromSnapshot(staleSnapshot)
frontend := monitor.buildBroadcastFrontendStateFromSnapshot(staleSnapshot, monitor.mockModeFence.begin())
if len(frontend.ActiveAlerts) != 1 {
t.Fatalf("expected 1 live active alert in frontend state, got %d", len(frontend.ActiveAlerts))
@@ -1396,7 +1396,7 @@ func TestBuildBroadcastFrontendStatePreservesSnapshotAlertsWithoutAlertManager(t
}},
}
frontend := monitor.buildBroadcastFrontendStateFromSnapshot(snapshot)
frontend := monitor.buildBroadcastFrontendStateFromSnapshot(snapshot, monitor.mockModeFence.begin())
if len(frontend.ActiveAlerts) != 1 {
t.Fatalf("expected snapshot alert to be preserved, got %#v", frontend.ActiveAlerts)
}
@@ -2274,7 +2274,7 @@ func TestUpdateResourceStore_IngestsSupplementalRecords(t *testing.T) {
t.Fatalf("RegisterPollProvider failed: %v", err)
}
monitor.updateResourceStore(models.StateSnapshot{})
monitor.updateResourceStore(models.StateSnapshot{}, monitor.mockModeFence.begin())
if store.snapshotCalls != 1 {
t.Fatalf("expected PopulateFromSnapshot to be called once, got %d", store.snapshotCalls)
@@ -2328,7 +2328,7 @@ func TestUpdateResourceStore_RecordsSupplementalChanges(t *testing.T) {
t.Fatalf("RegisterPollProvider failed: %v", err)
}
monitor.updateResourceStore(models.StateSnapshot{})
monitor.updateResourceStore(models.StateSnapshot{}, monitor.mockModeFence.begin())
if len(store.recordedChanges) != 1 {
t.Fatalf("expected 1 supplemental change recorded, got %d", len(store.recordedChanges))
@@ -2389,7 +2389,7 @@ func TestUpdateResourceStore_SuppressesProviderOwnedSnapshotSources(t *testing.T
PhysicalDisks: []models.PhysicalDisk{{}},
CephClusters: []models.CephCluster{{}},
Hosts: []models.Host{{}},
})
}, monitor.mockModeFence.begin())
if store.snapshotCalls != 1 {
t.Fatalf("expected PopulateFromSnapshot to be called once, got %d", store.snapshotCalls)
@@ -2435,7 +2435,7 @@ func TestUpdateResourceStore_IngestsRegisteredSupplementalProvider(t *testing.T)
monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider)
store.snapshotCalls = 0
store.recordsBySource = nil
monitor.updateResourceStore(models.StateSnapshot{})
monitor.updateResourceStore(models.StateSnapshot{}, monitor.mockModeFence.begin())
if store.snapshotCalls != 1 {
t.Fatalf("expected PopulateFromSnapshot to be called once, got %d", store.snapshotCalls)
@@ -2483,7 +2483,7 @@ func TestUpdateResourceStore_UsesAtomicStoreReplacementWhenAvailable(t *testing.
Hosts: []models.Host{{ID: "host-1", Hostname: "minipc", Status: "online"}},
}
monitor.updateResourceStore(snapshot)
monitor.updateResourceStore(snapshot, monitor.mockModeFence.begin())
if store.atomicCalls != 1 {
t.Fatalf("expected atomic populate to be called once, got %d", store.atomicCalls)
@@ -2535,7 +2535,7 @@ func TestUpdateResourceStore_SuppressesSnapshotForRegisteredSupplementalOwnershi
VMs: []models.VM{{}},
Containers: []models.Container{{}},
Hosts: []models.Host{{}},
})
}, monitor.mockModeFence.begin())
if len(store.lastSnapshot.Nodes) != 0 || len(store.lastSnapshot.VMs) != 0 || len(store.lastSnapshot.Containers) != 0 {
t.Fatalf("expected proxmox slices to be suppressed for direct provider ownership")
@@ -27,7 +27,7 @@ func (m *Monitor) previousGuestContextForInstance(instanceName string) previousG
hostAgentsByVMID: make(map[string]models.Host),
}
readState := m.GetUnifiedReadStateOrSnapshot()
readState := m.currentModeReadState()
if readState == nil {
return ctx
}
@@ -93,7 +93,7 @@ func (m *Monitor) previousGuestContextForInstance(instanceName string) previousG
func (m *Monitor) previousNodesForInstance(instanceName string) []models.Node {
prevInstanceNodes := make([]models.Node, 0)
readState := m.GetUnifiedReadStateOrSnapshot()
readState := m.currentModeReadState()
if readState == nil {
return prevInstanceNodes
}
+18 -11
View File
@@ -101,6 +101,9 @@ func (m *Monitor) checkBackupAlerts(ctx context.Context) {
}
defer m.backupAlertEvalMu.Unlock()
// Rollups and the guest lookup come from the fixture graph in mock mode;
// an evaluation that read them must not land after a mode change.
scope := m.mockModeFence.begin()
cfg := m.alertManager.GetConfig()
if !cfg.Enabled || !cfg.BackupDefaults.Enabled ||
(cfg.BackupDefaults.WarningDays <= 0 && cfg.BackupDefaults.CriticalDays <= 0) {
@@ -119,19 +122,23 @@ func (m *Monitor) checkBackupAlerts(ctx context.Context) {
// deadline, including connection-pool waiting, still needs visibility.
if !errors.Is(err, context.Canceled) {
log.Warn().Err(err).Msg("Failed to list recovery rollups for backup alerts")
m.alertManager.RaiseSystemAlert(alerts.SystemAlertInput{
Type: alerts.BackupEvaluationAlertType,
Level: alerts.AlertLevelWarning,
Fingerprint: "recovery-rollups-unavailable",
Message: "Backup-age alerts were not evaluated because recovery data could not be read. Existing backup alerts have been kept; check Pulse's logs.",
scope.run(func() {
m.alertManager.RaiseSystemAlert(alerts.SystemAlertInput{
Type: alerts.BackupEvaluationAlertType,
Level: alerts.AlertLevelWarning,
Fingerprint: "recovery-rollups-unavailable",
Message: "Backup-age alerts were not evaluated because recovery data could not be read. Existing backup alerts have been kept; check Pulse's logs.",
})
})
}
return
}
guestsByKey, guestsByVMID := buildGuestLookupsFromReadState(m.GetUnifiedReadStateOrSnapshot(), m.guestMetadataStore)
m.alertManager.CheckBackupsWithInventory(rollups, guestsByKey, guestsByVMID, m.backupInventoryScopeForAlerts())
// Only a complete read/evaluation (including a genuinely empty result)
// resolves the outage. In-flight checks are coalesced, not queued, so an
// older successful read cannot later erase a newer failure.
m.alertManager.ClearSystemAlert(alerts.BackupEvaluationAlertType)
guestsByKey, guestsByVMID := buildGuestLookupsFromReadState(m.currentModeReadState(), m.guestMetadataStore)
scope.run(func() {
m.alertManager.CheckBackupsWithInventory(rollups, guestsByKey, guestsByVMID, m.backupInventoryScopeForAlerts())
// Only a complete read/evaluation (including a genuinely empty result)
// resolves the outage. In-flight checks are coalesced, not queued, so an
// older successful read cannot later erase a newer failure.
m.alertManager.ClearSystemAlert(alerts.BackupEvaluationAlertType)
})
}