Skip to content

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

Open
mweibel wants to merge 2 commits into
mainfrom
fix/duplicate-lb-mutex
Open

Add per-service mutex to prevent concurrent reconciliation#57
mweibel wants to merge 2 commits 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 with service.UID keys.

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.

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.

FTR we discussed this offline and also considered using a simple sync.Mutex instead.

sync.Mutex can't be used because of the way the reconciliation works at this point: a single loadbalancer reconcile can take up to two minutes until it returns, fully blocking the mutex during that time.
Then we tried to reduce the scope of the Mutex, but because refetch is done not at the right places, the issue then can still happen.

We settled on the current approach with the intention to refactor reconcililation using api.RetryError instead of sleeps instead.

@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 4 times, most recently from 8fdb2f8 to bbb8874 Compare September 7, 2026 12:44
Prevents duplicate LB creation when multiple goroutines process
the same service concurrently (e.g. EnsureLoadBalancer +
UpdateLoadBalancer triggered by node sync).

Uses sync.Map with service UID keys.

Includes a failing unit test that reproduces the concurrent
creation race.
no zone found results in an invalid create loadbalancer call because the
zone is required.
Until the CCM annotated the server, the zone of a server is not known
and therefore the create lb call can't be made.
@mweibel
mweibel force-pushed the fix/duplicate-lb-mutex branch from bbb8874 to 92f8e55 Compare September 7, 2026 12:51
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