Skip to content

fix: implement atomic DataSource adapter update to prevent torn reads - #181

Draft
peatey wants to merge 1 commit into
developfrom
fix/atomic-datasource-adapter
Draft

fix: implement atomic DataSource adapter update to prevent torn reads#181
peatey wants to merge 1 commit into
developfrom
fix/atomic-datasource-adapter

Conversation

@peatey

@peatey peatey commented Apr 13, 2026

Copy link
Copy Markdown
Contributor
  • Add adapterState struct to group all four adapter components
  • Replace individual adapter fields with atomic.Pointer[adapterState]
  • Update all accessor methods to use atomic Load()
  • Modify Update() to swap complete state atomically
  • Add comprehensive race condition tests
  • All tests pass with go test -race (zero data races)

This ensures readers always see a fully consistent set of adapters (either all old or all new, never mixed) without any mutex contention.

- Add adapterState struct to group all four adapter components
- Replace individual adapter fields with atomic.Pointer[adapterState]
- Update all accessor methods to use atomic Load()
- Modify Update() to swap complete state atomically
- Add comprehensive race condition tests
- All tests pass with go test -race (zero data races)

This ensures readers always see a fully consistent set of adapters
(either all old or all new, never mixed) without any mutex contention.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR aims to prevent “torn reads” by grouping the OpenCost data source’s adapter components into a single state object and swapping that state atomically so readers observe a consistent set of adapters during updates.

Changes:

  • Introduces an adapterState container and replaces individual adapter fields with atomic.Pointer[adapterState].
  • Updates accessor methods (Metrics, ClusterMap, ClusterInfo, ClusterCache) to read adapters via state.Load().
  • Adds new concurrency-focused tests intended to validate race-safety and read consistency.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
kubecost/adapters/datasource.go Refactors the data source adapter to use an atomically-swapped state wrapper and updates accessors accordingly.
kubecost/adapters/datasource_test.go Adds tests for concurrent update/read behavior and basic post-update consistency checks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +56 to +59
Window: opencost.NewClosedWindow(
time.Now().Truncate(10*time.Minute),
time.Now().Truncate(10*time.Minute).Add(10*time.Minute),
),

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.
Comment on lines +76 to +82
// 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()
}

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.
Comment on lines +73 to +75
go func(id int) {
defer wg.Done()
for j := 0; j < 1000; j++ {

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.
Comment on lines 54 to +72
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)

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.
Comment on lines +14 to +16
// 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 {

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.
Comment on lines +16 to +20
type adapterState struct {
info *ClusterInfoProviderAdapter
mapAdpt *ClusterMapAdapter
cluster *ClusterCacheAdapter
metrics *MetricsQuerierAdapter

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.
Comment on lines +47 to +51
ClusterInfo: &clusters.ClusterInfo{
ID: "test-cluster-" + string(rune(i)),
Name: "test-cluster",
Provider: "test-provider",
},

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.
@peatey
peatey marked this pull request as draft April 13, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants