Skip to content

Commit 6c1387b

Browse files
authored
Merge branch 'main' into fix/fc-config-defaults
2 parents 66bf385 + 08dce7a commit 6c1387b

7 files changed

Lines changed: 329 additions & 61 deletions

File tree

pkg/epp/controller/pod_reconciler.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R
5959
return ctrl.Result{}, fmt.Errorf("unable to get pod - %w", err)
6060
}
6161

62-
c.updateDatastore(ctx, pod)
62+
if err := c.updateDatastore(ctx, pod); err != nil {
63+
return ctrl.Result{}, fmt.Errorf("failed to update datastore for pod %s - %w", req.NamespacedName, err)
64+
}
6365
return ctrl.Result{}, nil
6466
}
6567

@@ -91,16 +93,12 @@ func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
9193
Complete(c)
9294
}
9395

94-
func (c *PodReconciler) updateDatastore(ctx context.Context, pod *corev1.Pod) {
96+
func (c *PodReconciler) updateDatastore(ctx context.Context, pod *corev1.Pod) error {
9597
logger := log.FromContext(ctx)
9698
if !podutil.IsPodReady(pod) || !c.Datastore.PoolLabelsMatch(pod.Labels) {
9799
logger.V(logutil.DEBUG).Info("Pod removed or not added")
98100
c.Datastore.PodDelete(pod.Name)
99-
} else {
100-
if c.Datastore.PodUpdateOrAddIfNotExist(ctx, pod) {
101-
logger.V(logutil.DEFAULT).Info("Pod added")
102-
} else {
103-
logger.V(logutil.DEFAULT).Info("Pod already exists")
104-
}
101+
return nil
105102
}
103+
return c.Datastore.PodUpdateOrAddIfNotExist(ctx, pod)
106104
}

pkg/epp/controller/pod_reconciler_test.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535

3636
"github.com/llm-d/llm-d-router/pkg/epp/datalayer"
3737
"github.com/llm-d/llm-d-router/pkg/epp/datastore"
38+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
3839
"github.com/llm-d/llm-d-router/pkg/epp/util/pool"
3940
testutil "github.com/llm-d/llm-d-router/pkg/epp/util/testing"
4041
)
@@ -202,7 +203,7 @@ func TestPodReconciler(t *testing.T) {
202203
store := datastore.NewDatastore(t.Context(), epf)
203204
_ = store.PoolSet(t.Context(), fakeClient, pool.InferencePoolToEndpointPool(test.pool))
204205
for _, pod := range test.existingPods {
205-
store.PodUpdateOrAddIfNotExist(t.Context(), pod)
206+
_ = store.PodUpdateOrAddIfNotExist(t.Context(), pod)
206207
}
207208

208209
podReconciler := &PodReconciler{Reader: fakeClient, Datastore: store}
@@ -229,3 +230,40 @@ func TestPodReconciler(t *testing.T) {
229230
}
230231
}
231232
}
233+
234+
// TestPodReconciler_ErrorsOnRegistrationDrop verifies that Reconcile surfaces a dropped endpoint
235+
// registration as an error so controller-runtime requeues the pod (#2060).
236+
func TestPodReconciler_ErrorsOnRegistrationDrop(t *testing.T) {
237+
scheme := runtime.NewScheme()
238+
_ = clientgoscheme.AddToScheme(scheme)
239+
240+
incomingPod := testutil.FromBase(basePod1).
241+
Labels(map[string]string{"some-key": "some-val"}).
242+
ReadyCondition().ObjRef()
243+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(incomingPod).Build()
244+
245+
testPool := &v1.InferencePool{
246+
Spec: v1.InferencePoolSpec{
247+
TargetPorts: []v1.Port{{Number: v1.PortNumber(int32(8000))}},
248+
Selector: v1.LabelSelector{
249+
MatchLabels: map[v1.LabelKey]v1.LabelValue{"some-key": "some-val"},
250+
},
251+
},
252+
}
253+
// The factory stands in for a collector that is still registered for the endpoint (upsert
254+
// overlapping an in-flight delete) or fails to start.
255+
store := datastore.NewDatastore(t.Context(), &datalayer.FakeEndpointFactory{
256+
NewEndpointFn: func(_ context.Context, _ *fwkdl.EndpointMetadata) fwkdl.Endpoint { return nil },
257+
})
258+
_ = store.PoolSet(t.Context(), fakeClient, pool.InferencePoolToEndpointPool(testPool))
259+
260+
podReconciler := &PodReconciler{Reader: fakeClient, Datastore: store}
261+
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: incomingPod.Name, Namespace: incomingPod.Namespace}}
262+
263+
if _, err := podReconciler.Reconcile(context.Background(), req); err == nil {
264+
t.Error("expected Reconcile to return an error for a dropped endpoint registration, got nil")
265+
}
266+
if pods := store.PodList(datastore.AllPodsPredicate); len(pods) != 0 {
267+
t.Errorf("expected no pods in datastore, got %d", len(pods))
268+
}
269+
}

pkg/epp/datalayer/fake_factory.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
Copyright 2026 The llm-d Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package datalayer
18+
19+
import (
20+
"context"
21+
22+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
23+
)
24+
25+
// FakeEndpointFactory is a configurable EndpointFactory for tests. Each hook overrides the
26+
// corresponding method. A nil NewEndpointFn creates a plain endpoint with fresh metrics; nil
27+
// UpdateEndpointFn and ReleaseEndpointFn hooks are no-ops.
28+
type FakeEndpointFactory struct {
29+
NewEndpointFn func(ctx context.Context, meta *fwkdl.EndpointMetadata) fwkdl.Endpoint
30+
UpdateEndpointFn func(ctx context.Context, ep fwkdl.Endpoint)
31+
ReleaseEndpointFn func(ep fwkdl.Endpoint)
32+
}
33+
34+
var _ EndpointFactory = (*FakeEndpointFactory)(nil)
35+
36+
func (f *FakeEndpointFactory) NewEndpoint(ctx context.Context, meta *fwkdl.EndpointMetadata) fwkdl.Endpoint {
37+
if f.NewEndpointFn != nil {
38+
return f.NewEndpointFn(ctx, meta)
39+
}
40+
return fwkdl.NewEndpoint(meta, fwkdl.NewMetrics())
41+
}
42+
43+
func (f *FakeEndpointFactory) UpdateEndpoint(ctx context.Context, ep fwkdl.Endpoint) {
44+
if f.UpdateEndpointFn != nil {
45+
f.UpdateEndpointFn(ctx, ep)
46+
}
47+
}
48+
49+
func (f *FakeEndpointFactory) ReleaseEndpoint(ep fwkdl.Endpoint) {
50+
if f.ReleaseEndpointFn != nil {
51+
f.ReleaseEndpointFn(ep)
52+
}
53+
}

pkg/epp/datastore/datastore.go

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ import (
4444

4545
var (
4646
errPoolNotSynced = errors.New("InferencePool is not initialized in data store")
47-
AllPodsPredicate = func(_ fwkdl.Endpoint) bool { return true }
47+
// errRegistrationDropped reports an endpoint that could not be tracked: its collector is
48+
// still registered from an earlier registration (an upsert overlapping an in-flight delete)
49+
// or failed to start. Callers match it with errors.Is to decide whether to retry.
50+
errRegistrationDropped = errors.New("endpoint registration dropped: collector already registered or failed to start")
51+
AllPodsPredicate = func(_ fwkdl.Endpoint) bool { return true }
4852
)
4953

5054
const (
@@ -88,10 +92,15 @@ type Datastore interface {
8892

8993
// PodList lists pods matching the given predicate.
9094
PodList(predicate func(fwkdl.Endpoint) bool) []fwkdl.Endpoint
91-
PodUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.Pod) bool
95+
// PodUpdateOrAddIfNotExist stores or updates the endpoints for the given pod. It returns an
96+
// error when an endpoint registration was dropped (see upsertEndpoint); the pod is then not
97+
// tracked by the datastore and the caller must retry (e.g. by requeuing the reconcile).
98+
PodUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.Pod) error
9299
PodDelete(podName string)
93100

94101
// EndpointUpsert adds or updates an endpoint from a non-Kubernetes discovery source.
102+
// A dropped registration is logged; the endpoint stays untracked until the discovery
103+
// source re-emits it.
95104
EndpointUpsert(ctx context.Context, meta *fwkdl.EndpointMetadata)
96105
// EndpointDelete removes the endpoint with the given namespaced name.
97106
EndpointDelete(id types.NamespacedName)
@@ -130,6 +139,11 @@ type datastore struct {
130139
// key: types.NamespacedName, value: fwkdl.Endpoint
131140
pods *sync.Map
132141
epf datalayer.EndpointFactory
142+
// needsResync forces the next PoolSet to run podResyncAll even when the pool is unchanged.
143+
// PoolSet stores the pool before resyncing, so without this flag a PoolSet retried after a
144+
// resync failure would compare the incoming pool against the already-stored identical pool
145+
// and skip the resync. Guarded by mu.
146+
needsResync bool
133147
}
134148

135149
func (ds *datastore) WithEndpointPool(pool *datalayer.EndpointPool) Datastore {
@@ -167,7 +181,7 @@ func (ds *datastore) PoolSet(ctx context.Context, reader client.Reader, endpoint
167181
selectorChanged := oldEndpointPool == nil || !selectorEqual(oldEndpointPool.Selector, endpointPool.Selector)
168182
targetPortsChanged := oldEndpointPool != nil && !slices.Equal(oldEndpointPool.TargetPorts, endpointPool.TargetPorts)
169183

170-
if selectorChanged || targetPortsChanged {
184+
if selectorChanged || targetPortsChanged || ds.needsResync {
171185
logger.V(logutil.DEFAULT).Info("Updating endpoints", "selector", endpointPool.Selector, "targetPortsChanged", targetPortsChanged)
172186
// A full resync is required to address the following cases:
173187
// 1) At startup, the pod events may get processed before the pool is synced with the datastore,
@@ -178,8 +192,10 @@ func (ds *datastore) PoolSet(ctx context.Context, reader client.Reader, endpoint
178192
// 3) If the targetPorts changed, we need to resync to remove orphaned rank endpoints that no longer
179193
// exist in the new targetPorts configuration.
180194
if err := ds.podResyncAll(ctx, reader); err != nil {
195+
ds.needsResync = true
181196
return fmt.Errorf("failed to update pods according to the pool selector - %w", err)
182197
}
198+
ds.needsResync = false
183199
}
184200

185201
return nil
@@ -282,23 +298,30 @@ func (ds *datastore) PodList(predicate func(fwkdl.Endpoint) bool) []fwkdl.Endpoi
282298
return res
283299
}
284300

285-
func (ds *datastore) PodUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.Pod) bool {
301+
func (ds *datastore) PodUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.Pod) error {
286302
// Take a reference to pool under read lock to avoid racing with PoolSet().
287303
// This is safe because PoolSet() replaces the entire pool struct rather than
288304
// updating it in-place.
289305
ds.mu.RLock()
290306
pool := ds.pool
291307
ds.mu.RUnlock()
292308

309+
if pool == nil {
310+
// Without the pool's target ports the pod cannot be mapped to endpoints; the resync
311+
// triggered when the pool syncs picks the pod up.
312+
log.FromContext(ctx).V(logutil.DEBUG).Info("Skipping pod upsert, InferencePool not synced", "name", pod.Name)
313+
return nil
314+
}
293315
return ds.podUpdateOrAddIfNotExist(ctx, pod, pool)
294316
}
295317

296318
// podUpdateOrAddIfNotExist is the lock-free inner implementation.
297-
// Callers must ensure pool is a consistent snapshot (either read under lock
319+
// Callers must ensure pool is a non-nil consistent snapshot (either read under lock
298320
// or already held, as in podResyncAll which runs under ds.mu.Lock via PoolSet).
299-
func (ds *datastore) podUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.Pod, pool *datalayer.EndpointPool) bool {
321+
// It returns a joined error covering every endpoint of the pod whose registration was dropped.
322+
func (ds *datastore) podUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.Pod, pool *datalayer.EndpointPool) error {
300323
if pool == nil {
301-
return true
324+
return nil
302325
}
303326

304327
labels := make(map[string]string, len(pod.GetLabels()))
@@ -329,12 +352,26 @@ func (ds *datastore) podUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.P
329352
"pod", pod.Name, "namespace", pod.Namespace, "targetPorts", pool.TargetPorts)
330353
}
331354

332-
result := true
355+
added := false
356+
var errs []error
333357
existingEpSet := sets.Set[types.NamespacedName]{}
334358
for _, endpointMetadata := range pods {
335359
existingEpSet.Insert(endpointMetadata.NamespacedName)
336-
if ds.upsertEndpoint(ctx, endpointMetadata) {
337-
result = false
360+
created, err := ds.upsertEndpoint(ctx, endpointMetadata)
361+
if err != nil {
362+
errs = append(errs, err)
363+
continue
364+
}
365+
if created {
366+
added = true
367+
}
368+
}
369+
logger := log.FromContext(ctx)
370+
if len(errs) == 0 {
371+
if added {
372+
logger.V(logutil.DEFAULT).Info("Pod added", "name", pod.Name)
373+
} else {
374+
logger.V(logutil.DEFAULT).Info("Pod already exists", "name", pod.Name)
338375
}
339376
}
340377

@@ -351,7 +388,7 @@ func (ds *datastore) podUpdateOrAddIfNotExist(ctx context.Context, pod *corev1.P
351388
}
352389
}
353390

354-
return result
391+
return errors.Join(errs...)
355392
}
356393

357394
func (ds *datastore) PodDelete(podName string) {
@@ -366,7 +403,9 @@ func (ds *datastore) PodDelete(podName string) {
366403
}
367404

368405
func (ds *datastore) EndpointUpsert(ctx context.Context, meta *fwkdl.EndpointMetadata) {
369-
ds.upsertEndpoint(ctx, meta)
406+
if _, err := ds.upsertEndpoint(ctx, meta); err != nil {
407+
log.FromContext(ctx).Error(err, "failed to register endpoint", "endpoint", meta.NamespacedName)
408+
}
370409
}
371410

372411
func (ds *datastore) EndpointDelete(id types.NamespacedName) {
@@ -376,29 +415,38 @@ func (ds *datastore) EndpointDelete(id types.NamespacedName) {
376415
}
377416

378417
// upsertEndpoint stores or updates a single endpoint in the pods map.
379-
// Returns true if the endpoint was newly created, false if it already existed
380-
// or if NewEndpoint returned nil (duplicate-start race).
418+
// Returns true if the endpoint was newly created, false if it already existed.
381419
// Shared by EndpointUpsert and podUpdateOrAddIfNotExist.
382-
func (ds *datastore) upsertEndpoint(ctx context.Context, meta *fwkdl.EndpointMetadata) bool {
383-
existing, ok := ds.pods.Load(meta.NamespacedName)
384-
if !ok {
420+
//
421+
// It returns an error wrapping errRegistrationDropped when the endpoint cannot be tracked:
422+
// NewEndpoint returns nil when a collector is still registered for this endpoint or the
423+
// collector failed to start. When a concurrent upsert has stored an entry for the key, this
424+
// call's metadata is applied through the update path; when no entry exists (the upsert
425+
// overlapped an in-flight delete that removed the entry before deregistering the collector,
426+
// or the collector failed to start), the endpoint is untracked and the caller must retry.
427+
func (ds *datastore) upsertEndpoint(ctx context.Context, meta *fwkdl.EndpointMetadata) (bool, error) {
428+
for {
429+
if existing, ok := ds.pods.Load(meta.NamespacedName); ok {
430+
ep := existing.(fwkdl.Endpoint)
431+
if ep.GetMetadata().Equal(meta) {
432+
return false, nil
433+
}
434+
ep.UpdateMetadata(meta)
435+
ds.epf.UpdateEndpoint(ctx, ep)
436+
return false, nil
437+
}
385438
ep := ds.epf.NewEndpoint(ds.parentCtx, meta)
386439
if ep == nil {
387-
// NewEndpoint returns nil when a collector is already running for this
388-
// endpoint (duplicate reconcile race). The existing entry in ds.pods
389-
// is still valid; skip re-registering it.
390-
return false
440+
if _, ok := ds.pods.Load(meta.NamespacedName); ok {
441+
// A concurrent upsert won the registration; apply this call's metadata through
442+
// the update path above.
443+
continue
444+
}
445+
return false, fmt.Errorf("endpoint %s: %w", meta.NamespacedName, errRegistrationDropped)
391446
}
392447
ds.pods.Store(meta.NamespacedName, ep)
393-
return true
448+
return true, nil
394449
}
395-
ep := existing.(fwkdl.Endpoint)
396-
if ep.GetMetadata().Equal(meta) {
397-
return false
398-
}
399-
ep.UpdateMetadata(meta)
400-
ds.epf.UpdateEndpoint(ctx, ep)
401-
return false
402450
}
403451

404452
func (ds *datastore) podResyncAll(ctx context.Context, reader client.Reader) error {
@@ -414,19 +462,18 @@ func (ds *datastore) podResyncAll(ctx context.Context, reader client.Reader) err
414462
// Track active endpoints by their full name (including rank suffix).
415463
// This ensures orphaned rank endpoints are removed when targetPorts shrinks.
416464
activeEndpoints := sets.New[types.NamespacedName]()
465+
var errs []error
417466
for _, pod := range podList.Items {
418467
if !podutil.IsPodReady(&pod) {
419468
continue
420469
}
421-
namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace}
422470
// Calculate expected endpoint names based on current targetPorts.
423471
for idx := range ds.pool.TargetPorts {
424472
activeEndpoints.Insert(createEndpointNamespacedName(&pod, idx))
425473
}
426-
if !ds.podUpdateOrAddIfNotExist(ctx, &pod, ds.pool) {
427-
logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName)
428-
} else {
429-
logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName)
474+
if err := ds.podUpdateOrAddIfNotExist(ctx, &pod, ds.pool); err != nil {
475+
// Propagate so PoolSet fails; needsResync makes the retried PoolSet resync again.
476+
errs = append(errs, err)
430477
}
431478
}
432479

@@ -442,7 +489,7 @@ func (ds *datastore) podResyncAll(ctx context.Context, reader client.Reader) err
442489
return true
443490
})
444491

445-
return nil
492+
return errors.Join(errs...)
446493
}
447494

448495
// extractActivePorts extracts the active ports from a pod's annotations.

0 commit comments

Comments
 (0)