Skip to content

Commit 08dce7a

Browse files
authored
fix(datastore): retry dropped endpoint registrations instead of silently untracking pods (llm-d#2092)
* fix(datastore): surface dropped endpoint registrations and requeue pod reconciles An upsert that overlaps an in-flight delete (or whose collector fails to start) observes a nil endpoint from NewEndpoint with no surviving pods-map entry. Previously this was silently swallowed: the pod stayed untracked until the next pod event, which can leave the flow control dispatch cycle with an empty candidate list and saturation pinned at 1.0. upsertEndpoint now distinguishes the benign duplicate-start race (a concurrent upsert already stored the entry) from a dropped registration, which is returned as an error. PodReconciler propagates it so controller-runtime requeues with backoff; podResyncAll propagates it through PoolSet for the same effect on the pool reconciler; discovery sources log it. Related to llm-d#2060. Signed-off-by: Luke Van Drie <lukevandrie@google.com> * fix(datastore): retry resync after registration drop and apply metadata on upsert races Close gaps in the dropped-registration handling: - PoolSet stores the pool before resyncing, so a PoolSet retried after a resync failure compared the incoming pool against the already-stored identical pool and skipped the resync entirely. A needsResync flag now forces the retried PoolSet to resync until one succeeds. - upsertEndpoint applies the caller's metadata through the update path when a concurrent upsert wins the registration race, instead of silently discarding it. - The dropped-registration error is a sentinel (errRegistrationDropped) so callers and tests can match it with errors.Is. - PodUpdateOrAddIfNotExist skips and logs at DEBUG when the pool is not synced instead of logging a misleading 'Pod already exists'; the added/exists logging lives in one place. - Unchecked calls of the now error-returning PodUpdateOrAddIfNotExist are handled or explicitly discarded to satisfy errcheck. - The bespoke test endpoint factories collapse into a configurable datalayer.FakeEndpointFactory. Related to llm-d#2060. Signed-off-by: Luke Van Drie <lukevandrie@google.com> * chore: use llm-d Authors copyright in fake_factory.go Signed-off-by: Luke Van Drie <lukevandrie@google.com> * chore: fix requeueing typo flagged by CI Signed-off-by: Luke Van Drie <lukevandrie@google.com> --------- Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent cfa8a91 commit 08dce7a

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)