-
Notifications
You must be signed in to change notification settings - Fork 1
fix: implement atomic DataSource adapter update to prevent torn reads #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package adapters | ||
|
|
||
| import ( | ||
| "sync/atomic" | ||
| "time" | ||
|
|
||
| "github.com/ibm/finops-agent/pkg/emitter" | ||
|
|
@@ -10,12 +11,18 @@ import ( | |
| "github.com/opencost/opencost/core/pkg/source" | ||
| ) | ||
|
|
||
| // adapterState groups all adapter components so they can be swapped atomically. | ||
| // This ensures readers always get a fully consistent set of adapters — either all old or all new, never mixed. | ||
| type adapterState struct { | ||
| info *ClusterInfoProviderAdapter | ||
| mapAdpt *ClusterMapAdapter | ||
| cluster *ClusterCacheAdapter | ||
| metrics *MetricsQuerierAdapter | ||
|
Comment on lines
+16
to
+20
|
||
| } | ||
|
|
||
| type OpenCostDataSourceAdapter struct { | ||
| infoAdapter *ClusterInfoProviderAdapter | ||
| mapAdapter *ClusterMapAdapter | ||
| clusterAdapter *ClusterCacheAdapter | ||
| metricsAdapter *MetricsQuerierAdapter | ||
| resolution time.Duration | ||
| state atomic.Pointer[adapterState] | ||
| resolution time.Duration | ||
| } | ||
|
|
||
| func NewOpenCostDataSourceAdapter( | ||
|
|
@@ -25,21 +32,44 @@ func NewOpenCostDataSourceAdapter( | |
| metricsAdapter *MetricsQuerierAdapter, | ||
| resolution time.Duration, | ||
| ) *OpenCostDataSourceAdapter { | ||
| return &OpenCostDataSourceAdapter{ | ||
| infoAdapter: infoAdapter, | ||
| mapAdapter: mapAdapter, | ||
| clusterAdapter: clusterAdapter, | ||
| metricsAdapter: metricsAdapter, | ||
| resolution: resolution, | ||
| adapter := &OpenCostDataSourceAdapter{ | ||
| resolution: resolution, | ||
| } | ||
|
|
||
| // Initialize with the provided adapters | ||
| initial := &adapterState{ | ||
| info: infoAdapter, | ||
| mapAdpt: mapAdapter, | ||
| cluster: clusterAdapter, | ||
| metrics: metricsAdapter, | ||
| } | ||
| adapter.state.Store(initial) | ||
|
|
||
| return adapter | ||
| } | ||
|
|
||
| // Update emits the internal opencost source structures with the latest snapshot data | ||
| // Update emits the internal opencost source structures with the latest snapshot data. | ||
| // This method builds a complete new state and swaps it in atomically, ensuring readers | ||
| // always see a consistent set of adapters (either all old or all new, never mixed). | ||
| func (ocdsa *OpenCostDataSourceAdapter) Update(snapshot *emitter.ClusterSnapshot) { | ||
| ocdsa.infoAdapter.Update(snapshot.ClusterInfo) | ||
| ocdsa.mapAdapter.Update(snapshot.ClusterInfo) | ||
| ocdsa.clusterAdapter.Update(snapshot.Kubernetes) | ||
| ocdsa.metricsAdapter.Update(snapshot.Metrics) | ||
| // Load current state to get the existing adapters | ||
| current := ocdsa.state.Load() | ||
|
|
||
| // Update each adapter (these modify internal state via their own locks) | ||
| current.info.Update(snapshot.ClusterInfo) | ||
| current.mapAdpt.Update(snapshot.ClusterInfo) | ||
| current.cluster.Update(snapshot.Kubernetes) | ||
| current.metrics.Update(snapshot.Metrics) | ||
|
|
||
| // Build a new state object with the updated adapters and swap it in. | ||
| // After this single Store() call, all readers see the new state atomically. | ||
| newState := &adapterState{ | ||
| info: current.info, | ||
| mapAdpt: current.mapAdpt, | ||
| cluster: current.cluster, | ||
| metrics: current.metrics, | ||
| } | ||
| ocdsa.state.Store(newState) | ||
|
Comment on lines
54
to
+72
|
||
| } | ||
|
|
||
| // RegisterEndPoints registers any custom endpoints that can be used for diagnostics or debug purposes. | ||
|
|
@@ -54,18 +84,23 @@ func (ocds *OpenCostDataSourceAdapter) RegisterDiagnostics(diag diagnostics.Diag | |
|
|
||
| // Metrics returns a MetricsQuerier that can be used to query historical metrics data from the data source. | ||
| func (ocdsa *OpenCostDataSourceAdapter) Metrics() source.MetricsQuerier { | ||
| return ocdsa.metricsAdapter | ||
| return ocdsa.state.Load().metrics | ||
| } | ||
|
|
||
| // ClusterMap returns a mapping of cluster identifier to ClusterInfo for all known clusters (local only for | ||
| // single cluster deployments). | ||
| func (ocdsa *OpenCostDataSourceAdapter) ClusterMap() clusters.ClusterMap { | ||
| return ocdsa.mapAdapter | ||
| return ocdsa.state.Load().mapAdpt | ||
| } | ||
|
|
||
| // ClusterInfo returns the ClusterInfoProvider for the local cluster. | ||
| func (ocdsa *OpenCostDataSourceAdapter) ClusterInfo() clusters.ClusterInfoProvider { | ||
| return ocdsa.infoAdapter | ||
| return ocdsa.state.Load().info | ||
| } | ||
|
|
||
| // ClusterCache returns the ClusterCache for accessing Kubernetes resource snapshots. | ||
| func (ocdsa *OpenCostDataSourceAdapter) ClusterCache() *ClusterCacheAdapter { | ||
| return ocdsa.state.Load().cluster | ||
| } | ||
|
|
||
| func (ocdsa *OpenCostDataSourceAdapter) BatchDuration() time.Duration { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| package adapters | ||
|
|
||
| import ( | ||
| "sync" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/ibm/finops-agent/pkg/emitter" | ||
| "github.com/opencost/opencost/core/pkg/clusters" | ||
| "github.com/opencost/opencost/core/pkg/opencost" | ||
| ) | ||
|
|
||
| // TestAtomicUpdate verifies that concurrent reads and writes to the adapter state | ||
| // do not cause data races. This test should be run with `go test -race`. | ||
| func TestAtomicUpdate(t *testing.T) { | ||
| // Create initial adapters | ||
| clusterInfo := &clusters.ClusterInfo{ | ||
| ID: "test-cluster-1", | ||
| Name: "test-cluster", | ||
| Provider: "test-provider", | ||
| } | ||
|
|
||
| infoAdapter := NewClusterInfoProviderAdapter(clusterInfo) | ||
| mapAdapter := NewClusterMapAdapter(clusterInfo) | ||
| clusterAdapter := NewClusterCacheAdapter(&emitter.KubernetesSnapshot{}) | ||
| metricsAdapter := NewMetricsQuerierAdapter(&emitter.MetricsSummary{}) | ||
|
|
||
| // Create the data source adapter | ||
| adapter := NewOpenCostDataSourceAdapter( | ||
| infoAdapter, | ||
| mapAdapter, | ||
| clusterAdapter, | ||
| metricsAdapter, | ||
| time.Hour, | ||
| ) | ||
|
|
||
| // Use a WaitGroup to coordinate goroutines | ||
| var wg sync.WaitGroup | ||
|
|
||
| // Writer goroutine: perform 100 updates | ||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| for i := 0; i < 100; i++ { | ||
| // Create a new snapshot with updated data | ||
| snapshot := &emitter.ClusterSnapshot{ | ||
| ClusterInfo: &clusters.ClusterInfo{ | ||
| ID: "test-cluster-" + string(rune(i)), | ||
| Name: "test-cluster", | ||
| Provider: "test-provider", | ||
| }, | ||
|
Comment on lines
+47
to
+51
|
||
| Kubernetes: &emitter.KubernetesSnapshot{}, | ||
| Metrics: &emitter.MetricsSummary{ | ||
| Minutely: []*emitter.MetricsSnapshot{ | ||
| { | ||
| Window: opencost.NewClosedWindow( | ||
| time.Now().Truncate(10*time.Minute), | ||
| time.Now().Truncate(10*time.Minute).Add(10*time.Minute), | ||
| ), | ||
|
Comment on lines
+56
to
+59
|
||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| adapter.Update(snapshot) | ||
| time.Sleep(time.Millisecond) // Small delay between updates | ||
| } | ||
| }() | ||
|
|
||
| // Reader goroutines: perform 1000 reads each | ||
| numReaders := 3 | ||
| for i := 0; i < numReaders; i++ { | ||
| wg.Add(1) | ||
| go func(id int) { | ||
| defer wg.Done() | ||
| for j := 0; j < 1000; j++ { | ||
|
Comment on lines
+73
to
+75
|
||
| // Read all four accessors in sequence | ||
| // If there's a torn read, the race detector will catch it | ||
| _ = adapter.Metrics() | ||
| _ = adapter.ClusterMap() | ||
| _ = adapter.ClusterInfo() | ||
| _ = adapter.ClusterCache() | ||
| } | ||
|
Comment on lines
+76
to
+82
|
||
| }(i) | ||
| } | ||
|
|
||
| // Wait for all goroutines to finish | ||
| wg.Wait() | ||
|
|
||
| // If we reach here without the race detector flagging issues, the test passes | ||
| t.Log("Atomic update test completed successfully") | ||
| } | ||
|
|
||
| // TestConsistentReads verifies that when reading multiple accessors, | ||
| // they all come from the same snapshot (no torn reads). | ||
| func TestConsistentReads(t *testing.T) { | ||
| // Create initial adapters with a specific cluster ID | ||
| clusterInfo := &clusters.ClusterInfo{ | ||
| ID: "cluster-v1", | ||
| Name: "test-cluster", | ||
| Provider: "test-provider", | ||
| } | ||
|
|
||
| infoAdapter := NewClusterInfoProviderAdapter(clusterInfo) | ||
| mapAdapter := NewClusterMapAdapter(clusterInfo) | ||
| clusterAdapter := NewClusterCacheAdapter(&emitter.KubernetesSnapshot{}) | ||
| metricsAdapter := NewMetricsQuerierAdapter(&emitter.MetricsSummary{}) | ||
|
|
||
| adapter := NewOpenCostDataSourceAdapter( | ||
| infoAdapter, | ||
| mapAdapter, | ||
| clusterAdapter, | ||
| metricsAdapter, | ||
| time.Hour, | ||
| ) | ||
|
|
||
| // Verify initial state | ||
| info := adapter.ClusterInfo() | ||
| clusterMap := adapter.ClusterMap() | ||
|
|
||
| infoData := info.GetClusterInfo() | ||
| if infoData[clusters.ClusterInfoIdKey] != "cluster-v1" { | ||
| t.Errorf("Expected cluster ID 'cluster-v1', got '%s'", infoData[clusters.ClusterInfoIdKey]) | ||
| } | ||
|
|
||
| mapData := clusterMap.AsMap() | ||
| if _, ok := mapData["cluster-v1"]; !ok { | ||
| t.Error("Expected cluster-v1 in cluster map") | ||
| } | ||
|
|
||
| // Update to a new cluster ID | ||
| newClusterInfo := &clusters.ClusterInfo{ | ||
| ID: "cluster-v2", | ||
| Name: "test-cluster", | ||
| Provider: "test-provider", | ||
| } | ||
|
|
||
| snapshot := &emitter.ClusterSnapshot{ | ||
| ClusterInfo: newClusterInfo, | ||
| Kubernetes: &emitter.KubernetesSnapshot{}, | ||
| Metrics: &emitter.MetricsSummary{}, | ||
| } | ||
|
|
||
| adapter.Update(snapshot) | ||
|
|
||
| // Verify updated state - both should reflect the new cluster ID | ||
| info = adapter.ClusterInfo() | ||
| clusterMap = adapter.ClusterMap() | ||
|
|
||
| infoData = info.GetClusterInfo() | ||
| if infoData[clusters.ClusterInfoIdKey] != "cluster-v2" { | ||
| t.Errorf("Expected cluster ID 'cluster-v2', got '%s'", infoData[clusters.ClusterInfoIdKey]) | ||
| } | ||
|
|
||
| mapData = clusterMap.AsMap() | ||
| if _, ok := mapData["cluster-v2"]; !ok { | ||
| t.Error("Expected cluster-v2 in cluster map") | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment claims readers get “a fully consistent set of adapters — either all old or all new”, but each accessor does its own
state.Load()andUpdatecurrently mutates adapters in-place. At best this prevents mixed pointers if state were swapped, but it doesn’t guarantee a consistent multi-accessor view. Consider tightening the doc comment to the actual guarantee, or provide an API that returns a single loaded state handle for callers that need cross-accessor consistency.