diff --git a/internal/mock/fixture_data_version_test.go b/internal/mock/fixture_data_version_test.go new file mode 100644 index 000000000..6d91306b6 --- /dev/null +++ b/internal/mock/fixture_data_version_test.go @@ -0,0 +1,59 @@ +package mock + +import ( + "testing" +) + +func withMockEnabledForTest(t *testing.T) { + t.Helper() + previous := IsMockEnabled() + if err := SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { + if err := SetEnabled(previous); err != nil { + t.Fatalf("restore mock mode: %v", err) + } + }) +} + +func TestFixtureDataVersionAdvancesOnMetricTick(t *testing.T) { + withMockEnabledForTest(t) + + before := FixtureDataVersion() + updateMetrics(GetConfig()) + after := FixtureDataVersion() + + if after <= before { + t.Fatalf("expected FixtureDataVersion to advance on a metric tick, got %d -> %d", before, after) + } +} + +func TestUnifiedResourceSnapshotMemoizedPerDataVersion(t *testing.T) { + withMockEnabledForTest(t) + + first, firstFreshness := UnifiedResourceSnapshot() + if len(first) == 0 { + t.Fatal("expected mock resources") + } + second, secondFreshness := UnifiedResourceSnapshot() + if len(second) != len(first) { + t.Fatalf("expected identical memoized result, got %d vs %d resources", len(first), len(second)) + } + if &first[0] != &second[0] { + t.Fatal("expected the memoized snapshot to be returned for an unchanged data version") + } + if !firstFreshness.Equal(secondFreshness) { + t.Fatalf("expected identical freshness, got %v vs %v", firstFreshness, secondFreshness) + } + + updateMetrics(GetConfig()) + + third, _ := UnifiedResourceSnapshot() + if len(third) == 0 { + t.Fatal("expected mock resources after tick") + } + if &first[0] == &third[0] { + t.Fatal("expected a rebuilt snapshot after the data version advanced") + } +} diff --git a/internal/mock/integration.go b/internal/mock/integration.go index 7b092bbc1..585be04b2 100644 --- a/internal/mock/integration.go +++ b/internal/mock/integration.go @@ -23,10 +23,16 @@ var ( mockConfig = DefaultConfig enabled atomic.Bool fixtureRevision atomic.Uint64 - updateEveryNS atomic.Int64 - updateTicker *time.Ticker - stopUpdatesCh chan struct{} - updateLoopWg sync.WaitGroup + // fixtureDataVersion advances on EVERY observable mock-graph change: + // metric ticks as well as the structural changes that bump + // fixtureRevision. Caches of snapshots derived from the graph key on + // this; fixtureRevision stays structural-only so the expensive seeded + // trend history can be reused across monitor restarts within a process. + fixtureDataVersion atomic.Uint64 + updateEveryNS atomic.Int64 + updateTicker *time.Ticker + stopUpdatesCh chan struct{} + updateLoopWg sync.WaitGroup ) func init() { @@ -170,6 +176,7 @@ func enableMockMode(config MockConfig, fromInit bool) { mockConfig = config mockGraph = buildFixtureGraph(config, now) fixtureRevision.Add(1) + fixtureDataVersion.Add(1) enabled.Store(true) dataMu.Unlock() startUpdateLoop() @@ -205,6 +212,7 @@ func disableMockMode() { dataMu.Lock() mockGraph = emptyFixtureGraph() fixtureRevision.Add(1) + fixtureDataVersion.Add(1) dataMu.Unlock() log.Info().Msg("mock mode disabled") @@ -270,6 +278,15 @@ func updateMetrics(cfg MockConfig) { defer dataMu.Unlock() mockGraph.UpdateMetrics(cfg, time.Now()) + fixtureDataVersion.Add(1) +} + +// FixtureDataVersion returns a token that advances on every observable +// mock-graph change (metric ticks and structural changes alike). Derived +// snapshots cached against this token are current for as long as it is +// unchanged. +func FixtureDataVersion() uint64 { + return fixtureDataVersion.Load() } // GetConfig returns the current mock configuration. @@ -478,6 +495,7 @@ func SetMockConfig(cfg MockConfig) { if configChanged && enabled.Load() { mockGraph = buildFixtureGraph(normalized, time.Now()) fixtureRevision.Add(1) + fixtureDataVersion.Add(1) } dataMu.Unlock() diff --git a/internal/mock/platform_fixtures.go b/internal/mock/platform_fixtures.go index 6563bd4f9..8bf844c8d 100644 --- a/internal/mock/platform_fixtures.go +++ b/internal/mock/platform_fixtures.go @@ -3,6 +3,7 @@ package mock import ( "math" "strings" + "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/truenas" @@ -159,12 +160,46 @@ func SupplementalOwnedSources() []unifiedresources.DataSource { } } +// unifiedSnapshotMemo caches the package-level UnifiedResourceSnapshot result +// per FixtureDataVersion. Building the snapshot constructs a full throwaway +// registry (ingest-clone in, List-clone out) — before this memo, every +// consumer paid that on every read, which dominated the demo's allocation +// profile. +var unifiedSnapshotMemo struct { + mu sync.Mutex + valid bool + version uint64 + resources []unifiedresources.Resource + freshness time.Time +} + +// UnifiedResourceSnapshot returns the current mock world as unified +// resources. The returned slice and the nested data of its elements are +// shared between callers — treat them as read-only. Every current consumer +// either ingests them into a registry (which deep-clones) or copies the slice +// before writing top-level fields. func UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { if !IsMockEnabled() { return nil, time.Time{} } - return CurrentFixtureGraph().UnifiedResourceSnapshot() + unifiedSnapshotMemo.mu.Lock() + defer unifiedSnapshotMemo.mu.Unlock() + + // Read the version before fetching the graph: a tick that lands between + // the two can only make the cached data newer than its token, which + // forces a harmless rebuild on the next call — never a stale serve. + version := FixtureDataVersion() + if unifiedSnapshotMemo.valid && unifiedSnapshotMemo.version == version { + return unifiedSnapshotMemo.resources, unifiedSnapshotMemo.freshness + } + + resources, freshness := CurrentFixtureGraph().UnifiedResourceSnapshot() + unifiedSnapshotMemo.valid = true + unifiedSnapshotMemo.version = version + unifiedSnapshotMemo.resources = resources + unifiedSnapshotMemo.freshness = freshness + return resources, freshness } func (g FixtureGraph) UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { diff --git a/internal/monitoring/mock_unified_view_cache_test.go b/internal/monitoring/mock_unified_view_cache_test.go new file mode 100644 index 000000000..54696d1ab --- /dev/null +++ b/internal/monitoring/mock_unified_view_cache_test.go @@ -0,0 +1,40 @@ +package monitoring + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/mock" +) + +func TestCurrentUnifiedStateViewCachedBetweenMockTicks(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + t.Cleanup(func() { mustSetMockEnabled(t, previous) }) + + m := &Monitor{} + + first := m.currentUnifiedStateView() + if first.readState == nil { + t.Fatal("expected a read state from the mock branch") + } + second := m.currentUnifiedStateView() + if first.readState != second.readState { + t.Fatal("expected the cached view while the fixture data version is unchanged") + } + if len(first.resources) == 0 || len(second.resources) != len(first.resources) { + t.Fatalf("expected identical resource sets, got %d vs %d", len(first.resources), len(second.resources)) + } + + // Toggling mock mode rebuilds the fixture graph and advances the data + // version, which must invalidate the cached view. + mustSetMockEnabled(t, false) + mustSetMockEnabled(t, true) + + third := m.currentUnifiedStateView() + if third.readState == nil { + t.Fatal("expected a read state after the fixture graph rebuilt") + } + if third.readState == first.readState { + t.Fatal("expected a rebuilt view after the fixture data version advanced") + } +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 9ad3b26ae..b80f797c5 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1055,6 +1055,10 @@ type Monitor struct { config *config.Config state *models.State orgID string // Organization ID for tenant isolation (empty = default/legacy) + mockUnifiedViewMu sync.Mutex + mockUnifiedView monitorUnifiedStateView + mockUnifiedViewVersion uint64 + mockUnifiedViewValid bool pveClients map[string]PVEClientInterface pbsClients map[string]*pbs.Client pmgClients map[string]*pmg.Client @@ -4577,9 +4581,30 @@ func (m *Monitor) currentUnifiedStateView() monitorUnifiedStateView { } if mock.IsMockEnabled() { + // Read the version before the snapshot so a tick landing in between + // caches newer data under an older token (harmless rebuild next + // call) rather than ever serving stale data under a newer one. + version := mock.FixtureDataVersion() + m.mockUnifiedViewMu.Lock() + if m.mockUnifiedViewValid && m.mockUnifiedViewVersion == version { + view := m.mockUnifiedView + m.mockUnifiedViewMu.Unlock() + return view + } + m.mockUnifiedViewMu.Unlock() + resources, freshness := mock.UnifiedResourceSnapshot() if len(resources) > 0 || !freshness.IsZero() { - return monitorUnifiedStateViewFromResources(resources, freshness) + // Consumers share this view between ticks, mirroring the + // sharing semantics the persistent-store ReadState path has + // always had in real mode: views are read-only. + view := monitorUnifiedStateViewFromResources(resources, freshness) + m.mockUnifiedViewMu.Lock() + m.mockUnifiedView = view + m.mockUnifiedViewVersion = version + m.mockUnifiedViewValid = true + m.mockUnifiedViewMu.Unlock() + return view } return monitorUnifiedStateViewFromSnapshot(m.GetState()) }