Skip to content

Add per-service mutex to prevent concurrent reconciliation - #57

Open
mweibel wants to merge 1 commit into
mainfrom
fix/duplicate-lb-mutex
Open

Add per-service mutex to prevent concurrent reconciliation#57
mweibel wants to merge 1 commit into
mainfrom
fix/duplicate-lb-mutex

Conversation

@mweibel

@mweibel mweibel commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Prevents duplicate LB creation when multiple goroutines process the same service concurrently (e.g. EnsureLoadBalancer + UpdateLoadBalancer triggered by node sync).

Uses sync.Map keyed by service UID. Locks are not cleaned up on service deletion to avoid issues with late-arriving goroutines.

Includes a failing unit test that reproduces the concurrent creation race.

@mweibel
mweibel force-pushed the fix/duplicate-lb-mutex branch 2 times, most recently from 12a02c9 to 98b6400 Compare August 28, 2026 15:45
@mweibel
mweibel changed the base branch from main to feat/explicit-k8s-versions August 28, 2026 15:47
@mweibel
mweibel force-pushed the feat/explicit-k8s-versions branch from 5f32ead to 7be51a0 Compare August 31, 2026 06:20
@mweibel
mweibel force-pushed the fix/duplicate-lb-mutex branch from 98b6400 to 137ebee Compare August 31, 2026 06:20
Comment on lines +316 to +325
func (l *loadbalancer) lockForService(uid types.UID) func() {
rawMu, _ := l.muMap.LoadOrStore(string(uid), new(sync.Mutex))
mu := rawMu.(*sync.Mutex)
klog.V(4).InfoS("acquiring service lock", "uid", uid)
mu.Lock()

return func() {
klog.V(4).InfoS("releasing service lock", "uid", uid)
mu.Unlock()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I compared it with: https://github.com/cloudscale-ch/terraform-provider-cloudscale/blob/master/cloudscale/mutex_kv.go

  • any reason why you did not use the channel pattern you suggested in that repo?
  • I just now realized that both implementations do never actually delete map entries. In Terraform this should not be a problem, as the whole process is short-lived, in the CCM the process could live for weeks/months. Do you have an idea how we can address it? I don't, at least not a trivial one :)

@mweibel mweibel Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

any reason why you did not use the channel pattern you suggested in that repo?

the mutex_kv.go is a slightly different case: it used a loop with timer for waiting until the lock is there. It also supports cancellation with context to support terraform-native timeouts (and ctrl+c). It used TryLock for this and we changed it to a channel-based approach to get rid of the loop and timer and instead use "native" Go polling.

This case here is different and much simpler: we just acquire the lock and nobody else waits on it. We don't really need context cancellation here since it's running as a service. While it may happen that the parent context could be cancelled for some reason, the lock/unlock should not be the point where this gets respected (and hasn't been so far).

I just now realized that both implementations do never actually delete map entries. In Terraform this should not be a problem, as the whole process is short-lived, in the CCM the process could live for weeks/months. Do you have an idea how we can address it? I don't, at least not a trivial one :)

That's true and I tried to address this in the commit message:

Locks are not cleaned up on service deletion to avoid issues with late-arriving goroutines.

How often this happens is questionable but it avoids potential issues without much cost: Even large clusters won't have more than a couple loadbalancers. Keeping them around would be a couple 100 bytes (one entry is ~150B). Of course if somebody creates and deletes services 1000s of times it eventually could crash the CCM, but that would rather speak for a misuse of the system than anything else TBH.

Still, We could remove the entry for a service in two ways:

  1. once EnsureLoadBalancerDeleted is successfully done
  2. Add a goroutine which periodically removes unused mutexes. This would need to track which services still exist.

point 1 is rather trivial, I just wonder if there are any edge cases. I don't think so, but I didn't add this change because the benefit is not 100% clear to me.

Let me know what you think - happy to add the deletion logic if you feel it's valuable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

As an aside: we could also use a plain map with an accompanying single mutex in this case. I think either approach would work well.

@mweibel
mweibel force-pushed the fix/duplicate-lb-mutex branch from 137ebee to 66acf8a Compare September 1, 2026 09:07
@mweibel
mweibel changed the base branch from feat/explicit-k8s-versions to main September 1, 2026 09:07
@mweibel
mweibel force-pushed the fix/duplicate-lb-mutex branch from 66acf8a to 519158d Compare September 1, 2026 09:08
Prevents duplicate LB creation when multiple goroutines process
the same service concurrently (e.g. EnsureLoadBalancer +
UpdateLoadBalancer triggered by node sync).

Uses sync.Map keyed by service UID. Locks are not cleaned up
on service deletion to avoid issues with late-arriving goroutines.

Includes a failing unit test that reproduces the concurrent
creation race.
@mweibel
mweibel force-pushed the fix/duplicate-lb-mutex branch from 519158d to c9e310e Compare September 1, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants