diff --git a/internal/mock/metric_runtime.go b/internal/mock/metric_runtime.go index 0cd58f5391..a9583d404c 100644 --- a/internal/mock/metric_runtime.go +++ b/internal/mock/metric_runtime.go @@ -3,6 +3,7 @@ package mock import ( "math" "strings" + "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/mockmodel" @@ -218,6 +219,36 @@ type MetricSampler struct { roles map[string]string } +// runtimeMetricSampler caches only derived, immutable roles. The graph data +// version changes on both metric ticks and structural updates, so new series +// observe the current canonical fixture while existing samplers remain bound +// to their captured graph. Private graph/global persona updates cannot replace +// this canonical snapshot. +var runtimeMetricSampler struct { + sync.Mutex + version uint64 + sampler MetricSampler +} + +// CurrentMetricSampler avoids cloning the entire demo estate and resolving +// every resource's persona for every chart series. Graph publication and the +// version read share dataMu; the cached role map is never mutated after return. +func CurrentMetricSampler() MetricSampler { + dataMu.RLock() + defer dataMu.RUnlock() + if !enabled.Load() { + return MetricSampler{} + } + runtimeMetricSampler.Lock() + defer runtimeMetricSampler.Unlock() + version := fixtureDataVersion.Load() + if runtimeMetricSampler.sampler.roles == nil || runtimeMetricSampler.version != version { + runtimeMetricSampler.sampler = NewMetricSampler(mockGraph) + runtimeMetricSampler.version = version + } + return runtimeMetricSampler.sampler +} + func NewMetricSampler(graph FixtureGraph) MetricSampler { return MetricSampler{roles: buildMetricRoleRegistry(graph)} } diff --git a/internal/mock/metric_runtime_test.go b/internal/mock/metric_runtime_test.go index 775d2ec8b8..276305dc66 100644 --- a/internal/mock/metric_runtime_test.go +++ b/internal/mock/metric_runtime_test.go @@ -1,7 +1,10 @@ package mock import ( + "fmt" "math" + "reflect" + "sync" "testing" "time" @@ -89,3 +92,118 @@ func TestMetricSamplerRemainsBoundToFixtureGraph(t *testing.T) { } } } + +// Keep the graph and its version coherent, just like runtime publication. Do +// not restore an old version: existing readers may still hold its sampler. +func installMetricSamplerGraphForTest(tb testing.TB, graph FixtureGraph) { + tb.Helper() + dataMu.Lock() + previousGraph, previousEnabled := mockGraph, enabled.Load() + mockGraph = graph + enabled.Store(true) + fixtureDataVersion.Add(1) + dataMu.Unlock() + tb.Cleanup(func() { + dataMu.Lock() + mockGraph = previousGraph + enabled.Store(previousEnabled) + fixtureDataVersion.Add(1) + dataMu.Unlock() + }) +} + +func TestCurrentMetricSamplerTracksGraphVersionWithoutGlobalPersonaLeak(t *testing.T) { + graph := FixtureGraph{State: models.StateSnapshot{Containers: []models.Container{{ID: "neutral-155", Name: "backup-orchestrator"}}}} + installMetricSamplerGraphForTest(t, graph) + at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC) + timestamps := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)} + initial := CurrentMetricSampler() + want := NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("container", "neutral-155", "memory", timestamps) + if !reflect.DeepEqual(initial.SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) { + t.Fatal("cached sampler differs from graph-bound reference") + } + previousRegistry := currentMetricRoleRegistry() + t.Cleanup(func() { setMetricRoleRegistry(previousRegistry) }) + setMetricRoleRegistry(map[string]string{metricRoleRegistryKey("container", "neutral-155"): metricRoleDatabase}) + if !reflect.DeepEqual(CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) { + t.Fatal("private/global persona update replaced canonical graph sampling") + } + if allocs := testing.AllocsPerRun(100, func() { _ = CurrentMetricSampler() }); allocs != 0 { + t.Fatalf("warm sampler acquisition allocates %v times; graph must not be cloned per series", allocs) + } + dataMu.Lock() + mockGraph.State.Containers[0].Name = "database-primary" + fixtureDataVersion.Add(1) + dataMu.Unlock() + updated := CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", timestamps) + reference := NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("container", "neutral-155", "memory", timestamps) + if !reflect.DeepEqual(updated, reference) || reflect.DeepEqual(updated, want) { + t.Fatal("new graph version did not refresh its immutable persona snapshot") + } + if !reflect.DeepEqual(initial.SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) { + t.Fatal("publishing a new sampler mutated an already-returned sampler") + } + enabled.Store(false) + if got := CurrentMetricSampler(); got.roles != nil { + t.Fatal("disabled mock mode retained canonical roles") + } +} + +func TestCurrentMetricSamplerConcurrentSeriesRemainSnapshotBound(t *testing.T) { + graph := FixtureGraph{State: models.StateSnapshot{Containers: []models.Container{{ID: "neutral-155", Name: "backup-orchestrator"}}}} + backup := NewMetricSampler(graph) + graph.State.Containers[0].Name = "database-primary" + database := NewMetricSampler(graph) + installMetricSamplerGraphForTest(t, graph) + at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC) + times := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)} + first, second := backup.SampleMetricSeries("container", "neutral-155", "memory", times), database.SampleMetricSeries("container", "neutral-155", "memory", times) + var workers sync.WaitGroup + for i := 0; i < 8; i++ { + workers.Add(1) + go func() { + defer workers.Done() + for j := 0; j < 50; j++ { + got := CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", times) + if !reflect.DeepEqual(got, first) && !reflect.DeepEqual(got, second) { + t.Error("one series mixed two graph revisions") + return + } + } + }() + } + for i := 0; i < 50; i++ { + name := "backup-orchestrator" + if i%2 == 0 { + name = "database-primary" + } + dataMu.Lock() + mockGraph.State.Containers[0].Name = name + fixtureDataVersion.Add(1) + dataMu.Unlock() + } + workers.Wait() +} + +func BenchmarkCanonicalMetricSeriesSamplerReuse(b *testing.B) { + graph := FixtureGraph{State: models.StateSnapshot{VMs: make([]models.VM, 1000)}} + for i := range graph.State.VMs { + graph.State.VMs[i] = models.VM{ID: fmt.Sprintf("neutral-%d", i), Name: "database-primary"} + } + installMetricSamplerGraphForTest(b, graph) + at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC) + times := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)} + _ = CurrentMetricSampler() + b.Run("legacy-cloned-graph-per-series", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("vm", "neutral-1", "memory", times) + } + }) + b.Run("current-immutable-sampler", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = CurrentMetricSampler().SampleMetricSeries("vm", "neutral-1", "memory", times) + } + }) +} diff --git a/internal/monitoring/mock_metrics_history.go b/internal/monitoring/mock_metrics_history.go index 6c3b360426..6b10b55612 100644 --- a/internal/monitoring/mock_metrics_history.go +++ b/internal/monitoring/mock_metrics_history.go @@ -525,7 +525,7 @@ func normalizeMockMetricTimestamp(at time.Time, interval time.Duration) time.Tim func canonicalMetricSeries(resourceType, resourceID, metric string, timestamps []time.Time) []float64 { return canonicalMetricSeriesWithSampler( - mock.NewMetricSampler(mock.CurrentFixtureGraph()), + mock.CurrentMetricSampler(), resourceType, resourceID, metric,