This is an automated email from the ASF dual-hosted git repository.

manirajv06 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/yunikorn-k8shim.git


The following commit(s) were added to refs/heads/master by this push:
     new 102e24bb [YUNIKORN-3369] Abort AssumePod retry loop on shim shutdown 
(#1078)
102e24bb is described below

commit 102e24bb5b8892c39e41f5ffa36e537ac1c890c2
Author: jimmycmlin <[email protected]>
AuthorDate: Mon Aug 31 14:03:26 2026 +0530

    [YUNIKORN-3369] Abort AssumePod retry loop on shim shutdown (#1078)
    
    Closes: #1078
    
    Signed-off-by: Manikandan R <[email protected]>
---
 pkg/cache/scheduler_callback.go           | 36 ++++++++++++----
 pkg/cache/scheduler_callback_test.go      | 58 ++++++++++++++++++++++++-
 pkg/common/test/volumebinder_mock.go      | 17 +++++---
 pkg/common/test/volumebinder_mock_test.go | 39 +++++++++++++++++
 pkg/shim/scheduler.go                     | 23 ++++++++--
 pkg/shim/scheduler_mock_test.go           |  6 ++-
 pkg/shim/scheduler_test.go                | 71 +++++++++++++++++++++++++++++--
 7 files changed, 226 insertions(+), 24 deletions(-)

diff --git a/pkg/cache/scheduler_callback.go b/pkg/cache/scheduler_callback.go
index a2cdaf0a..dd53828f 100644
--- a/pkg/cache/scheduler_callback.go
+++ b/pkg/cache/scheduler_callback.go
@@ -19,12 +19,13 @@
 package cache
 
 import (
+       "context"
+       "errors"
        "fmt"
        "time"
 
        "go.uber.org/zap"
        "k8s.io/apimachinery/pkg/util/wait"
-       "k8s.io/client-go/util/retry"
 
        "github.com/apache/yunikorn-k8shim/pkg/common/utils"
        "github.com/apache/yunikorn-k8shim/pkg/dispatcher"
@@ -37,13 +38,14 @@ import (
 // asynchronously to avoid blocking the scheduler.
 type AsyncRMCallback struct {
        context *Context
+       stopCtx context.Context
 }
 
 var _ api.ResourceManagerCallback = &AsyncRMCallback{}
 var _ api.StateDumpPlugin = &AsyncRMCallback{}
 
-func NewAsyncRMCallback(ctx *Context) *AsyncRMCallback {
-       return &AsyncRMCallback{context: ctx}
+func NewAsyncRMCallback(ctx *Context, stopCtx context.Context) 
*AsyncRMCallback {
+       return &AsyncRMCallback{context: ctx, stopCtx: stopCtx}
 }
 
 func (callback *AsyncRMCallback) UpdateAllocation(response 
*si.AllocationResponse) error {
@@ -70,13 +72,31 @@ func (callback *AsyncRMCallback) UpdateAllocation(response 
*si.AllocationRespons
                        Duration: time.Second,
                        Cap:      30 * time.Second,
                }
-               err := retry.OnError(backOff, func(err error) bool {
-                       log.Log(log.ShimRMCallback).Error("AssumePod failed, 
retrying", zap.Error(err))
-                       return true
-               }, func() error {
-                       return callback.context.AssumePod(alloc.AllocationKey, 
alloc.NodeID)
+               var lastErr error
+               // ConditionWithContextFunc receives a context derived from 
callback.stopCtx; closure accesses callback.context directly.
+               err := wait.ExponentialBackoffWithContext(callback.stopCtx, 
backOff, func(_ context.Context) (bool, error) {
+                       if assumeErr := 
callback.context.AssumePod(alloc.AllocationKey, alloc.NodeID); assumeErr != nil 
{
+                               log.Log(log.ShimRMCallback).Error("AssumePod 
failed, retrying", zap.Error(assumeErr))
+                               lastErr = assumeErr
+                               return false, nil
+                       }
+                       return true, nil
                })
                if err != nil {
+                       if errors.Is(err, context.Canceled) || errors.Is(err, 
context.DeadlineExceeded) {
+                               // Shim is shutting down 
(context.DeadlineExceeded is checked defensively for future-proofing).
+                               // Do not fall through to 
rollbackOnAssumePodFailure:
+                               // rollback re-queues the ask to the core 
scheduler, which is still running
+                               // independently of stopCtx/dispatcher.Stop(), 
so the core immediately
+                               // re-allocates this task and re-triggers 
UpdateAllocation, which hits this
+                               // same canceled context and rolls back again — 
an unbounded busy loop on the
+                               // RM-proxy callback goroutine that Stop() can 
never interrupt. See YUNIKORN-3369.
+                               log.Log(log.ShimRMCallback).Info("AssumePod 
retry aborted due to context cancellation", zap.Error(err))
+                               continue
+                       }
+                       if wait.Interrupted(err) && lastErr != nil {
+                               err = lastErr
+                       }
                        if task.IsPlaceholder() {
                                // Placeholder tasks do not have volume 
bindings, so AssumePod failure
                                // is unexpected and unrecoverable; wrap the 
error with context.
diff --git a/pkg/cache/scheduler_callback_test.go 
b/pkg/cache/scheduler_callback_test.go
index 90619a77..bf97c437 100644
--- a/pkg/cache/scheduler_callback_test.go
+++ b/pkg/cache/scheduler_callback_test.go
@@ -19,6 +19,7 @@
 package cache
 
 import (
+       ctx "context"
        "encoding/json"
        "strings"
        "sync/atomic"
@@ -166,6 +167,61 @@ func TestUpdateAllocation_PlaceholderTask_AssumePodFails(t 
*testing.T) {
        assert.Assert(t, assumePodErrorFound, "no event with reason 
'AssumePodError' was recorded")
 }
 
+// TestUpdateAllocation_ContextCanceled_ProcessesRemainingBatch verifies that a
+// canceled stopCtx aborts AssumePod retries without dropping the rest of the
+// response.New batch or rolling back the already-canceled allocation.
+func TestUpdateAllocation_ContextCanceled_ProcessesRemainingBatch(t 
*testing.T) {
+       _, context := initCallbackTest(t, false, false)
+       defer dispatcher.UnregisterAllEventHandlers()
+       defer dispatcher.Stop()
+
+       // register a second task under the same app so a single 
UpdateAllocation
+       // batch can carry two `New` allocations; mirrors initCallbackTest's own
+       // pod-construction pattern for taskUID1 (Annotations-based app ID, no
+       // Spec.NodeName so it is unassigned and must go through AssumePod).
+       pod2 := &v1.Pod{
+               TypeMeta: apis.TypeMeta{
+                       Kind:       "Pod",
+                       APIVersion: "v1",
+               },
+               ObjectMeta: apis.ObjectMeta{
+                       Name: "yunikorn-test-00002",
+                       UID:  taskUID2,
+                       Annotations: map[string]string{
+                               constants.AnnotationApplicationID: appID,
+                       },
+               },
+               Spec: v1.PodSpec{SchedulerName: "yunikorn"},
+       }
+       context.AddPod(pod2)
+       task2 := context.getTask(appID, taskUID2)
+       assert.Assert(t, task2 != nil)
+       task2.sm.SetState(TaskStates().Scheduling)
+
+       // swap in a callback whose stopCtx is already canceled, simulating
+       // KubernetesShim.Stop() having already run before this batch arrives.
+       cancelCtx, cancel := ctx.WithCancel(ctx.Background())
+       cancel()
+       callback := NewAsyncRMCallback(context, cancelCtx)
+
+       task1 := context.getTask(appID, taskUID1)
+       err := callback.UpdateAllocation(&si.AllocationResponse{
+               New: []*si.Allocation{
+                       {ApplicationID: appID, AllocationKey: taskUID1, NodeID: 
fakeNodeName},
+                       {ApplicationID: appID, AllocationKey: taskUID2, NodeID: 
fakeNodeName},
+               },
+       })
+       assert.NilError(t, err, "a canceled-context batch must not surface an 
error to the RM proxy")
+       assert.Assert(t, !context.schedulerCache.IsAssumedPod(taskUID1))
+       assert.Assert(t, !context.schedulerCache.IsAssumedPod(taskUID2))
+       // setAllocationKey runs unconditionally as the first step of each loop
+       // iteration, before the retry/cancellation logic; task2 only has it set
+       // if the loop actually reached the second allocation.
+       assert.Equal(t, taskUID2, task2.GetAllocationKey(), "second allocation 
in the batch must still be reached after the first hits a canceled context")
+       assert.Equal(t, TaskStates().Scheduling, task1.GetTaskState(), 
"canceled allocation must not be rolled back")
+       assert.Equal(t, TaskStates().Scheduling, task2.GetTaskState(), 
"canceled allocation must not be rolled back")
+}
+
 func TestUpdateAllocation_NewTask_PodAlreadyAssigned(t *testing.T) {
        callback, context := initCallbackTest(t, true, false)
        defer dispatcher.UnregisterAllEventHandlers()
@@ -592,7 +648,7 @@ func initCallbackTest(t *testing.T, podAssigned, 
placeholder bool) (*AsyncRMCall
        dispatcher.Start()
        dispatcher.RegisterEventHandler("TestAppHandler", 
dispatcher.EventTypeApp, context.ApplicationEventHandler())
        dispatcher.RegisterEventHandler("TestTaskHandler", 
dispatcher.EventTypeTask, context.TaskEventHandler())
-       callback := NewAsyncRMCallback(context)
+       callback := NewAsyncRMCallback(context, t.Context())
        apiProvider.MockSchedulerAPIUpdateNodeFn(func(request *si.NodeRequest) 
error {
                for _, node := range request.Nodes {
                        dispatcher.Dispatch(CachedSchedulerNodeEvent{
diff --git a/pkg/common/test/volumebinder_mock.go 
b/pkg/common/test/volumebinder_mock.go
index 607d24e4..a5c0cbe0 100644
--- a/pkg/common/test/volumebinder_mock.go
+++ b/pkg/common/test/volumebinder_mock.go
@@ -38,11 +38,12 @@ type VolumeBinderMock struct {
        bindError           error
        conflictReasons     volumebinding.ConflictReasons
 
-       podVolumeClaim    *volumebinding.PodVolumeClaims
-       podVolumes        *volumebinding.PodVolumes
-       allBound          bool
-       revertCalledCount int
-       bindCount         atomic.Int32
+       podVolumeClaim        *volumebinding.PodVolumeClaims
+       podVolumes            *volumebinding.PodVolumes
+       allBound              bool
+       revertCalledCount     int
+       bindCount             atomic.Int32
+       volumeClaimsCallCount atomic.Int32
 }
 
 func NewVolumeBinderMock() *VolumeBinderMock {
@@ -52,10 +53,10 @@ func NewVolumeBinderMock() *VolumeBinderMock {
 }
 
 func (v *VolumeBinderMock) GetPodVolumeClaims(_ klog.Logger, _ *v1.Pod) 
(*volumebinding.PodVolumeClaims, error) {
+       v.volumeClaimsCallCount.Add(1)
        if v.volumeClaimError != nil {
                return nil, v.volumeClaimError
        }
-
        return v.podVolumeClaim, nil
 }
 
@@ -104,6 +105,10 @@ func (v *VolumeBinderMock) GetBindCount() int32 {
        return v.bindCount.Load()
 }
 
+func (v *VolumeBinderMock) GetVolumeClaimsCallCount() int32 {
+       return v.volumeClaimsCallCount.Load()
+}
+
 func (v *VolumeBinderMock) EnableVolumeClaimsError(message string) {
        v.volumeClaimError = errors.New(message)
 }
diff --git a/pkg/common/test/volumebinder_mock_test.go 
b/pkg/common/test/volumebinder_mock_test.go
new file mode 100644
index 00000000..db9bce01
--- /dev/null
+++ b/pkg/common/test/volumebinder_mock_test.go
@@ -0,0 +1,39 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package test
+
+import (
+       "testing"
+
+       "gotest.tools/v3/assert"
+       "k8s.io/klog/v2"
+)
+
+func TestVolumeBinderMock_GetVolumeClaimsCallCount(t *testing.T) {
+       binder := NewVolumeBinderMock()
+       assert.Equal(t, int32(0), binder.GetVolumeClaimsCallCount())
+
+       _, err := binder.GetPodVolumeClaims(klog.NewKlogr(), nil)
+       assert.NilError(t, err)
+       assert.Equal(t, int32(1), binder.GetVolumeClaimsCallCount())
+
+       _, err = binder.GetPodVolumeClaims(klog.NewKlogr(), nil)
+       assert.NilError(t, err)
+       assert.Equal(t, int32(2), binder.GetVolumeClaimsCallCount(), "call 
count must increment on every invocation")
+}
diff --git a/pkg/shim/scheduler.go b/pkg/shim/scheduler.go
index 9b8b5c9b..3d55070a 100644
--- a/pkg/shim/scheduler.go
+++ b/pkg/shim/scheduler.go
@@ -51,6 +51,7 @@ type KubernetesShim struct {
        callback             api.ResourceManagerCallback
        stopChan             chan struct{}
        stopOnce             sync.Once
+       cancelCallbacks      ctx.CancelFunc
        lock                 *locking.RWMutex
        outstandingAppsFound bool
 }
@@ -81,7 +82,7 @@ func NewShimScheduler(scheduler api.SchedulerAPI, configs 
*conf.SchedulerConf, b
                log.Log(log.Shim).Fatal("problem in creating the context")
                return nil
        }
-       rmCallback := cache.NewAsyncRMCallback(context)
+       rmCallback, cancelCallbacks := newCallbackWithCancel(context)
 
        eventBroadcaster := k8events.NewBroadcaster(&k8events.EventSinkImpl{
                Interface: kubeClient.GetClientSet().EventsV1()})
@@ -94,17 +95,29 @@ func NewShimScheduler(scheduler api.SchedulerAPI, configs 
*conf.SchedulerConf, b
                events.SetRecorder(eventRecorder)
        }
 
-       return newShimSchedulerInternal(context, apiFactory, rmCallback)
+       return newShimSchedulerInternal(context, apiFactory, rmCallback, 
cancelCallbacks)
 }
 
-// this is visible for testing
-func newShimSchedulerInternal(ctx *cache.Context, apiFactory 
client.APIProvider, cb api.ResourceManagerCallback) *KubernetesShim {
+// newCallbackWithCancel builds the async RM callback used by the shim together
+// with the CancelFunc that aborts any in-flight AssumePod retry when the shim
+// shuts down. Split out of NewShimScheduler so it can be tested without a live
+// Kubernetes client.
+func newCallbackWithCancel(cacheCtx *cache.Context) 
(api.ResourceManagerCallback, ctx.CancelFunc) {
+       callbackCtx, cancelCallbacks := ctx.WithCancel(ctx.Background())
+       return cache.NewAsyncRMCallback(cacheCtx, callbackCtx), cancelCallbacks
+}
+
+func newShimSchedulerInternal(ctx *cache.Context, apiFactory 
client.APIProvider, cb api.ResourceManagerCallback, cancelCallbacks 
ctx.CancelFunc) *KubernetesShim {
+       if cancelCallbacks == nil {
+               cancelCallbacks = func() {}
+       }
        ss := &KubernetesShim{
                apiFactory:           apiFactory,
                context:              ctx,
                phManager:            
cache.NewPlaceholderManager(apiFactory.GetAPIs()),
                callback:             cb,
                stopChan:             make(chan struct{}),
+               cancelCallbacks:      cancelCallbacks,
                lock:                 &locking.RWMutex{},
                outstandingAppsFound: false,
        }
@@ -231,6 +244,8 @@ func (ss *KubernetesShim) Stop() {
                stopped = true
                log.Log(log.ShimScheduler).Info("stopping scheduler")
                close(ss.stopChan)
+               // stop the AssumePod retry loop running on the RM proxy 
callback goroutine
+               ss.cancelCallbacks()
                // stop the client library code that communicates with 
Kubernetes
                ss.apiFactory.Stop()
                // stop the placeholder manager
diff --git a/pkg/shim/scheduler_mock_test.go b/pkg/shim/scheduler_mock_test.go
index 96f4024f..2aa0353f 100644
--- a/pkg/shim/scheduler_mock_test.go
+++ b/pkg/shim/scheduler_mock_test.go
@@ -19,6 +19,7 @@
 package shim
 
 import (
+       ctx "context"
        "fmt"
        "sync/atomic"
        "testing"
@@ -66,9 +67,10 @@ func (fc *MockScheduler) init() {
        mockedAPIProvider.GetAPIs().SchedulerAPI = fc.rmProxy
        events.SetRecorder(events.NewMockedRecorder())
 
+       callbackCtx, cancelCallbacks := ctx.WithCancel(ctx.Background())
        context := cache.NewContext(mockedAPIProvider)
-       rmCallback := cache.NewAsyncRMCallback(context)
-       ss := newShimSchedulerInternal(context, mockedAPIProvider, rmCallback)
+       rmCallback := cache.NewAsyncRMCallback(context, callbackCtx)
+       ss := newShimSchedulerInternal(context, mockedAPIProvider, rmCallback, 
cancelCallbacks)
 
        fc.context = context
        fc.scheduler = ss
diff --git a/pkg/shim/scheduler_test.go b/pkg/shim/scheduler_test.go
index c3e482bf..d7a8cae5 100644
--- a/pkg/shim/scheduler_test.go
+++ b/pkg/shim/scheduler_test.go
@@ -173,7 +173,7 @@ func TestSchedulerRegistrationFailed(t *testing.T) {
                })
 
        ctx := cache.NewContext(mockedAPIProvider)
-       shim := newShimSchedulerInternal(ctx, mockedAPIProvider, callback)
+       shim := newShimSchedulerInternal(ctx, mockedAPIProvider, callback, 
func() {})
        assert.Error(t, shim.Run(), "some error")
        assertStopChannelClosed(t, shim)
 
@@ -183,7 +183,7 @@ func TestSchedulerRegistrationFailed(t *testing.T) {
 
 func TestSchedulerStopClosesStopChannel(t *testing.T) {
        mockedAPIProvider := client.NewMockedAPIProvider(false)
-       shim := newShimSchedulerInternal(cache.NewContext(mockedAPIProvider), 
mockedAPIProvider, nil)
+       shim := newShimSchedulerInternal(cache.NewContext(mockedAPIProvider), 
mockedAPIProvider, nil, func() {})
 
        shim.Stop()
        assertStopChannelClosed(t, shim)
@@ -195,7 +195,7 @@ func TestSchedulerStopClosesStopChannel(t *testing.T) {
 func TestSchedulerStopStopsAPIFactory(t *testing.T) {
        mockedAPIProvider := client.NewMockedAPIProvider(false)
        apiProvider := &trackingAPIProvider{APIProvider: mockedAPIProvider}
-       shim := newShimSchedulerInternal(cache.NewContext(apiProvider), 
apiProvider, nil)
+       shim := newShimSchedulerInternal(cache.NewContext(apiProvider), 
apiProvider, nil, func() {})
 
        shim.Stop()
        assert.Check(t, apiProvider.stopped.Load(), "API provider should be 
stopped with the scheduler")
@@ -203,6 +203,24 @@ func TestSchedulerStopStopsAPIFactory(t *testing.T) {
        shim.Stop()
 }
 
+func TestNewCallbackWithCancel(t *testing.T) {
+       mockedAPIProvider := client.NewMockedAPIProvider(false)
+       shimCtx := cache.NewContext(mockedAPIProvider)
+
+       callback, cancel := newCallbackWithCancel(shimCtx)
+       assert.Assert(t, callback != nil, "expected a non-nil callback")
+       assert.Assert(t, cancel != nil, "expected a non-nil cancel func")
+       cancel() // must be safe to invoke
+}
+
+func TestSchedulerNilCancelCallbacksFallsBackToNoop(t *testing.T) {
+       mockedAPIProvider := client.NewMockedAPIProvider(false)
+       shim := newShimSchedulerInternal(cache.NewContext(mockedAPIProvider), 
mockedAPIProvider, nil, nil)
+
+       assert.Assert(t, shim.cancelCallbacks != nil, "nil cancelCallbacks must 
fall back to a no-op")
+       shim.cancelCallbacks() // must not panic
+       shim.Stop()            // Stop() invokes cancelCallbacks(); must not 
panic either
+}
 func assertStopChannelClosed(t *testing.T, shim *KubernetesShim) {
        t.Helper()
        select {
@@ -324,6 +342,53 @@ func TestAssumePodError(t *testing.T) {
                "no pods should be bound when AssumePod always fails")
 }
 
+// TestAssumePodError_AbortsOnShutdown verifies that Stop() interrupts the
+// AssumePod retry loop instead of letting it run out its full 30-step,
+// ~29s backoff after shutdown.
+func TestAssumePodError_AbortsOnShutdown(t *testing.T) {
+       cluster := MockScheduler{}
+       cluster.init()
+       binder := test.NewVolumeBinderMock()
+       binder.EnableVolumeClaimsError("unable to get volume claims")
+       cluster.apiProvider.SetVolumeBinder(binder)
+       assert.NilError(t, cluster.start(), "failed to start cluster")
+
+       err := cluster.updateConfig(configData, nil)
+       assert.NilError(t, err, "update config failed")
+       addNode(&cluster, "node-1")
+
+       taskResource := common.NewResourceBuilder().
+               AddResource(siCommon.Memory, 1000).
+               AddResource(siCommon.CPU, 1).
+               Build()
+       pod1 := createTestPod("root.a", "app0001", "task0001", taskResource)
+       cluster.AddPod(pod1)
+
+       // wait until the retry loop has made repeated attempts, proving it is
+       // actively blocking the RM callback goroutine on the 1s-per-step 
backoff
+       err = utils.WaitForCondition(func() bool {
+               return binder.GetVolumeClaimsCallCount() >= 2
+       }, 100*time.Millisecond, 10*time.Second)
+       assert.NilError(t, err, "AssumePod retry never made repeated attempts")
+
+       countAtStop := binder.GetVolumeClaimsCallCount()
+       cluster.stop()
+
+       // at most the single attempt already in flight when Stop() ran may 
still land
+       err = utils.WaitForCondition(func() bool {
+               return binder.GetVolumeClaimsCallCount() <= countAtStop+1
+       }, 50*time.Millisecond, time.Second)
+       assert.NilError(t, err, "AssumePod made more than one additional 
attempt after shim.Stop()")
+
+       settledCount := binder.GetVolumeClaimsCallCount()
+       // the pre-fix backoff retries roughly once per second; wait more than a
+       // full step to prove the loop actually exited rather than merely being
+       // mid-sleep
+       time.Sleep(2 * time.Second)
+       assert.Equal(t, settledCount, binder.GetVolumeClaimsCallCount(),
+               "AssumePod kept retrying after shim.Stop(); the retry loop did 
not abort on shutdown")
+}
+
 func TestForeignPodTracking(t *testing.T) {
        cluster := MockScheduler{}
        cluster.init()


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to