Skip to content

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

Merged
mweibel merged 2 commits into
mainfrom
fix/duplicate-lb-mutex
Sep 10, 2026
Merged

Add per-service mutex to prevent concurrent reconciliation#57
mweibel merged 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 thread pkg/cloudscale_ccm/loadbalancer.go
@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 5 times, most recently from bbb8874 to 92f8e55 Compare September 7, 2026 12:51
Comment thread pkg/cloudscale_ccm/loadbalancer.go Outdated
if serviceDeleted {
// clean up
klog.V(4).InfoS("cleaning up service lock", "uid", service.UID)
l.muMap.Delete(service.UID)

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 think there are two problems:

  1. I fear there is never anything removed: keys are strings but here we remove UID

  2. I think there is the possibility that two callers end up holding different locks for the same UID.

Example interleaving (please challenge my understanding):

                                               muMap[uid]  Mutex 1           Mutex 2
A  LoadOrStore finds no entry, stores Mutex 1  Mutex 1     A acquired           -
B  LoadOrStore finds Mutex 1, waits on it      Mutex 1     A acquired, B waits  -
A  unlock()                                    Mutex 1     free, B waits     -
A  Delete removes the entry                    -           free, B waits     -
B  wakes on the mutex it already had           -           B acquired           -
C  LoadOrStore finds no entry, stores Mutex 2  Mutex 2     B acquired           C acquired

in the final line: there are two mutexes for the same UID acquired by different callers. only one of them is in map.

not sure about the best fix - maybe revert to the more trivial variant, that leaks a few bytes? :/

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.

I fear there is never anything removed: keys are strings but here we remove UID

UID is a string type alias (type UID string) but fair, the original code wouldn't need the string or we should have them in both places.

I think there is the possibility that two callers end up holding different locks for the same UID.

You mean this case:

package main

import (
	"fmt"
	"sync"
	"time"
)

var muMap sync.Map

func acquire(prefix, s string) func() {
	rawMu, _ := muMap.LoadOrStore(s, new(sync.Mutex))
	mu := rawMu.(*sync.Mutex)
	start := time.Now()
	fmt.Println(prefix, "acquiring lock")
	mu.Lock()

	return func() {
		fmt.Println(prefix, "releasing service lock", "uid", s, "duration", time.Since(start))
		mu.Unlock()
	}
}

func main() {
	s := "test"

	var wg sync.WaitGroup

	wg.Go(func() {
		unlock := acquire("a", s)
		time.Sleep(1 * time.Second)
		unlock()
		fmt.Println("deleting lock")
		muMap.Delete(s)
	})
	wg.Go(func() {
		time.Sleep(10*time.Millisecond)
		unlock := acquire("b", s)
		time.Sleep(2 * time.Second)
		unlock()
	})
	wg.Go(func() {
		time.Sleep(1500 * time.Millisecond)
		unlock := acquire("c", s)
		defer unlock()
		// critical section
	})

	wg.Wait()
	fmt.Println("done")
}

Right?

This is a valid concern in general, but I'm not sure if it's valid in this case.

The goroutine C you mention is IMO only possible in theory.
Reason: when EnsureLoadbalancerDeleted is called, the service is either deleted or doesn't need a LoadBalancer anymore.
In both cases, nobody would call EnsureLoadbalancer or UpdateLoadbalancer or EnsureLoadbalancerDeleted anymore.
The case where a goroutine C would happen, might be in laggy scenarios. For example: when CCM's informer (the watcher on the service resource) hasn't seen the deletionTimestamp yet and would queue a reconciliation. But even in this case, the race window is small because the rest would need to match as well.

That doesn't mean however, that the issue doesn't exist. To be safe, we'd need to get rid of the cleanup again and live with the fact that we have a small leak there. If that proves to be an issue, we could still add a GC on schedule which cleans up not locks in the map which don't exist anymore.

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.

actually, you're right about the first issue - I haven't considered the comparison it does. Will fix.

Comment thread pkg/cloudscale_ccm/reconcile_test.go
@mweibel
mweibel force-pushed the fix/duplicate-lb-mutex branch from 92f8e55 to a36d066 Compare September 8, 2026 13:12
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 a36d066 to f5e9f4b Compare September 8, 2026 14:36
@mweibel
mweibel merged commit ca35059 into main Sep 10, 2026
18 of 19 checks passed
@mweibel
mweibel deleted the fix/duplicate-lb-mutex branch September 10, 2026 06:20
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