Cache mock unified snapshots instead of rebuilding registries per read

In mock mode every unified read-state access built two throwaway
registries: mock.UnifiedResourceSnapshot constructed one to derive the
resource list, and the monitor's currentUnifiedStateView ingested that
list into another, deep-cloning all resources both ways. Chart requests,
broadcasts, alert evaluation, and API reads each repaid that full cost —
the dominant share of the demo's 76TB/9.5d allocation churn, since every
one of those reads runs against a world that only changes on the 2-second
mock tick.

Introduce fixtureDataVersion, a token that advances on every observable
mock-graph change (metric ticks and the structural changes that bump
fixtureRevision, which stays structural-only so seeded trend history
remains reusable). Memoize the package-level UnifiedResourceSnapshot and
the monitor's mock-branch state view against it, so consumers between
ticks share one immutable build. Sharing mirrors the semantics the
persistent-store ReadState path has always had in real mode: all
consumers were audited — they ingest (which clones), copy before
top-level writes, or build fresh outputs. Real-mode paths are untouched.

Contract-Neutral: mock snapshot memoization: identical data served from cache, no contract delta
This commit is contained in:
rcourtman
2026-08-05 17:17:45 +01:00
parent 78af7a881f
commit 518a5e2294
5 changed files with 183 additions and 6 deletions
@@ -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")
}
}
+22 -4
View File
@@ -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()
+36 -1
View File
@@ -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) {
@@ -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")
}
}
+26 -1
View File
@@ -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())
}