Skip to content

Commit a1f0b01

Browse files
authored
fix: race condition in PodUpdateOrAddIfNotExist reading pool without lock (#2578)
* fix: race condition in PodUpdateOrAddIfNotExist reading pool without lock `PodUpdateOrAddIfNotExist()` accessed `ds.pool` (nil check, `TargetPorts` iteration) at some call sites without acquiring `ds.mu.RLock()`. Meanwhile, `PoolSet()` replaces `ds.pool` under `ds.mu.Lock()`. Under concurrent pod reconciliation and pool updates, this causes: 1. **Nil pointer panic**: `PoolSet(ctx, reader, nil)` sets `ds.pool = nil` between the nil check and `ds.pool.TargetPorts` access. 2. **Inconsistent reads**: the first half of the function reads old pool's `TargetPorts` while the second half reads the new pool's, leading to orphaned or missing endpoints. **Fix**: snapshot `ds.pool` under `RLock` at function entry and use the local copy throughout. * update comments based on ahg-g's comments
1 parent 65503c1 commit a1f0b01

2 files changed

Lines changed: 85 additions & 12 deletions

File tree

pkg/epp/datastore/datastore.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,21 @@ func (ds *datastore) PodList(predicate func(fwkdl.Endpoint) bool) []fwkdl.Endpoi
272272
}
273273

274274
func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool {
275-
if ds.pool == nil {
275+
// Take a reference to pool under read lock to avoid racing with PoolSet().
276+
// This is safe because PoolSet() replaces the entire pool struct rather than
277+
// updating it in-place.
278+
ds.mu.RLock()
279+
pool := ds.pool
280+
ds.mu.RUnlock()
281+
282+
return ds.podUpdateOrAddIfNotExist(pod, pool)
283+
}
284+
285+
// podUpdateOrAddIfNotExist is the lock-free inner implementation.
286+
// Callers must ensure pool is a consistent snapshot (either read under lock
287+
// or already held, as in podResyncAll which runs under ds.mu.Lock via PoolSet).
288+
func (ds *datastore) podUpdateOrAddIfNotExist(pod *corev1.Pod, pool *datalayer.EndpointPool) bool {
289+
if pool == nil {
276290
return true
277291
}
278292

@@ -282,12 +296,12 @@ func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool {
282296
}
283297

284298
modelServerMetricsPort := 0
285-
if len(ds.pool.TargetPorts) == 1 {
299+
if len(pool.TargetPorts) == 1 {
286300
modelServerMetricsPort = int(ds.modelServerMetricsPort)
287301
}
288302
pods := []*fwkdl.EndpointMetadata{}
289-
activePorts := ds.extractActivePorts(pod)
290-
for idx, port := range ds.pool.TargetPorts {
303+
activePorts := extractActivePorts(pod, pool.TargetPorts)
304+
for idx, port := range pool.TargetPorts {
291305
if !activePorts.Has(port) {
292306
continue
293307
}
@@ -324,7 +338,7 @@ func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool {
324338
}
325339

326340
// remove endpoints that are no longer active in the pool
327-
for idx, port := range ds.pool.TargetPorts {
341+
for idx, port := range pool.TargetPorts {
328342
if activePorts.Has(port) {
329343
continue
330344
}
@@ -372,7 +386,7 @@ func (ds *datastore) podResyncAll(ctx context.Context, reader client.Reader) err
372386
for idx := range ds.pool.TargetPorts {
373387
activeEndpoints.Insert(createEndpointNamespacedName(&pod, idx))
374388
}
375-
if !ds.PodUpdateOrAddIfNotExist(&pod) {
389+
if !ds.podUpdateOrAddIfNotExist(&pod, ds.pool) {
376390
logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName)
377391
} else {
378392
logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName)
@@ -395,8 +409,8 @@ func (ds *datastore) podResyncAll(ctx context.Context, reader client.Reader) err
395409
}
396410

397411
// extractActivePorts extracts the active ports from a pod's annotations.
398-
func (ds *datastore) extractActivePorts(pod *corev1.Pod) sets.Set[int] {
399-
allPorts := sets.New(ds.pool.TargetPorts...)
412+
func extractActivePorts(pod *corev1.Pod, targetPorts []int) sets.Set[int] {
413+
allPorts := sets.New(targetPorts...)
400414
annotations := pod.GetAnnotations()
401415
portsAnnotation, ok := annotations[activePortsAnnotation]
402416
if !ok {

pkg/epp/datastore/datastore_test.go

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"net"
2323
"reflect"
2424
"strconv"
25+
"sync"
2526
"testing"
2627
"time"
2728

@@ -1095,6 +1096,67 @@ func TestActivePortEndpointRemoval(t *testing.T) {
10951096
}
10961097
}
10971098

1099+
// TestPodUpdateOrAddIfNotExist_ConcurrentPoolSet verifies that PodUpdateOrAddIfNotExist
1100+
// does not race with PoolSet. Before the fix, PodUpdateOrAddIfNotExist read ds.pool
1101+
// without holding ds.mu, which could panic or corrupt data when PoolSet concurrently
1102+
// replaces ds.pool under the write lock.
1103+
// Run with: go test -race -run TestPodUpdateOrAddIfNotExist_ConcurrentPoolSet
1104+
func TestPodUpdateOrAddIfNotExist_ConcurrentPoolSet(t *testing.T) {
1105+
scheme := runtime.NewScheme()
1106+
_ = clientgoscheme.AddToScheme(scheme)
1107+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
1108+
1109+
ctx := context.Background()
1110+
period := time.Second
1111+
epf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, period)
1112+
ds := NewDatastore(ctx, epf, 0)
1113+
1114+
pool := pooltuil.InferencePoolToEndpointPool(
1115+
testutil.MakeInferencePool("pool1").
1116+
Namespace("default").
1117+
Selector(map[string]string{"app": "vllm"}).
1118+
TargetPorts(8000).ObjRef(),
1119+
)
1120+
_ = ds.PoolSet(ctx, fakeClient, pool)
1121+
1122+
pod := &corev1.Pod{
1123+
ObjectMeta: metav1.ObjectMeta{
1124+
Name: "pod1",
1125+
Namespace: "default",
1126+
Labels: map[string]string{"app": "vllm"},
1127+
},
1128+
Status: corev1.PodStatus{
1129+
PodIP: "10.0.0.1",
1130+
Conditions: []corev1.PodCondition{
1131+
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
1132+
},
1133+
},
1134+
}
1135+
1136+
var wg sync.WaitGroup
1137+
wg.Add(2)
1138+
1139+
// Goroutine 1: repeatedly call PoolSet (including nil to simulate reset).
1140+
go func() {
1141+
defer wg.Done()
1142+
for range 500 {
1143+
_ = ds.PoolSet(ctx, fakeClient, pool)
1144+
_ = ds.PoolSet(ctx, fakeClient, nil)
1145+
_ = ds.PoolSet(ctx, fakeClient, pool)
1146+
}
1147+
}()
1148+
1149+
// Goroutine 2: repeatedly call PodUpdateOrAddIfNotExist.
1150+
go func() {
1151+
defer wg.Done()
1152+
for range 1000 {
1153+
ds.PodUpdateOrAddIfNotExist(pod)
1154+
}
1155+
}()
1156+
1157+
wg.Wait()
1158+
}
1159+
10981160
func TestExtractActivePorts(t *testing.T) {
10991161
tests := []struct {
11001162
name string
@@ -1214,10 +1276,7 @@ func TestExtractActivePorts(t *testing.T) {
12141276

12151277
for _, tt := range tests {
12161278
t.Run(tt.name, func(t *testing.T) {
1217-
ds := NewDatastore(context.Background(), nil, 0).WithEndpointPool(&datalayer.EndpointPool{
1218-
TargetPorts: tt.validPorts,
1219-
})
1220-
activePorts := ds.extractActivePorts(tt.pod)
1279+
activePorts := extractActivePorts(tt.pod, tt.validPorts)
12211280
if !reflect.DeepEqual(activePorts, tt.expectedPorts) {
12221281
t.Errorf("ExtractActivePorts() ports = %v, want %v", activePorts, tt.expectedPorts)
12231282
}

0 commit comments

Comments
 (0)