Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 54 additions & 19 deletions kubecost/adapters/datasource.go
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"
Expand All @@ -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 {
Comment on lines +14 to +16

Copilot AI Apr 13, 2026

Copy link

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() and Update currently 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.

Copilot uses AI. Check for mistakes.
info *ClusterInfoProviderAdapter
mapAdpt *ClusterMapAdapter
cluster *ClusterCacheAdapter
metrics *MetricsQuerierAdapter
Comment on lines +16 to +20

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapAdpt is a hard-to-scan abbreviation and inconsistent with the constructor parameter mapAdapter. Consider renaming it to something clearer like mapAdapter or clusterMap to avoid confusion.

Copilot uses AI. Check for mistakes.
}

type OpenCostDataSourceAdapter struct {
infoAdapter *ClusterInfoProviderAdapter
mapAdapter *ClusterMapAdapter
clusterAdapter *ClusterCacheAdapter
metricsAdapter *MetricsQuerierAdapter
resolution time.Duration
state atomic.Pointer[adapterState]
resolution time.Duration
}

func NewOpenCostDataSourceAdapter(
Expand All @@ -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

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update mutates the adapters in-place (info/map/cluster/metrics) and then stores a new adapterState that points to the exact same adapter instances. This does not achieve the stated goal of “either all old or all new”: readers can still observe partially-updated adapter contents while Update is in progress, and the final Store is effectively a no-op. If atomic consistency across all four adapters is required, build a fresh set of adapters from snapshot (or an immutable snapshot wrapper) and Store that state without mutating the previously-published adapters; otherwise, keep the simpler non-atomic fields and remove the misleading atomic swap.

Copilot uses AI. Check for mistakes.
}

// RegisterEndPoints registers any custom endpoints that can be used for diagnostics or debug purposes.
Expand All @@ -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 {
Expand Down
158 changes: 158 additions & 0 deletions kubecost/adapters/datasource_test.go
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

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"test-cluster-" + string(rune(i)) does not append the decimal representation of i; it converts i to a single Unicode code point (including control characters for small values). Use strconv.Itoa(i)/fmt.Sprintf("%d", i) to generate deterministic IDs.

Copilot uses AI. Check for mistakes.
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

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The window bounds are built from two separate time.Now() calls. If the calls straddle a 10-minute boundary, start and end may not represent a single 10m window, making the test flaky. Capture now := time.Now().Truncate(10*time.Minute) once and derive both start/end from it.

Copilot uses AI. Check for mistakes.
},
},
},
}
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

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reader goroutine takes an id parameter but never uses it. Consider removing the parameter (or using it for debugging/logging) to avoid dead code and keep the test minimal.

Copilot uses AI. Check for mistakes.
// 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

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says “If there's a torn read, the race detector will catch it”, but -race only detects unsynchronized concurrent memory access, not logical consistency/torn-read conditions. If the intent is to validate consistency, add explicit assertions (e.g., read a single state handle and verify fields correspond to the same snapshot) instead of relying on the race detector.

Copilot uses AI. Check for mistakes.
}(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")
}
}
Loading