This is an automated email from the ASF dual-hosted git repository.
wilfred-s 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 f4ee41b1 [YUNIKORN-2884] handling volume and pod binding failure
retries (#1062)
f4ee41b1 is described below
commit f4ee41b134454da421731e22a3f4af4c0360c55c
Author: Aditya Maheshwari <[email protected]>
AuthorDate: Wed Aug 19 14:59:13 2026 +1000
[YUNIKORN-2884] handling volume and pod binding failure retries (#1062)
Handle volume bind and bind failures on the k8shim side and retry when
logical::
* volume bind timeout fails directly without retry
* volume API call failures are retried with backoff
* bind API call failures are retried with backoff
When retries are exhausted revert the pod to pending in the k8shim and
core.
Closes: #1062
Signed-off-by: Wilfred Spiegelenburg <[email protected]>
---
go.mod | 3 +-
go.sum | 6 +-
pkg/cache/task.go | 139 ++++++++++++++++-----
pkg/cache/task_test.go | 230 +++++++++++++++++++++++++++++++++++
pkg/common/test/volumebinder_mock.go | 11 ++
pkg/shim/scheduler_test.go | 19 +--
6 files changed, 365 insertions(+), 43 deletions(-)
diff --git a/go.mod b/go.mod
index 349efd5e..6085beec 100644
--- a/go.mod
+++ b/go.mod
@@ -21,7 +21,7 @@ module github.com/apache/yunikorn-k8shim
go 1.26.0
require (
- github.com/apache/yunikorn-core v0.0.0-20260728123950-900bd603df82
+ github.com/apache/yunikorn-core v0.0.0-20260810093905-7dc1287c839f
github.com/apache/yunikorn-scheduler-interface
v0.0.0-20260727092410-674338955bdf
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
@@ -92,6 +92,7 @@ require (
github.com/prometheus/procfs v0.19.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ github.com/tidwall/btree v1.8.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
diff --git a/go.sum b/go.sum
index 974ceb67..a999efb9 100644
--- a/go.sum
+++ b/go.sum
@@ -10,8 +10,8 @@ github.com/alexbrainman/sspi
v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod
h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/antlr4-go/antlr/v4 v4.13.1
h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod
h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
-github.com/apache/yunikorn-core v0.0.0-20260728123950-900bd603df82
h1:uvaD7WchXd9nLkXArGjdul1OQfxXO+ukvI0VJJ0hAwk=
-github.com/apache/yunikorn-core v0.0.0-20260728123950-900bd603df82/go.mod
h1:Z/8pYK5VeDk9Q74vTCm3FnCVm1DtciaGYjl5/P7kO+M=
+github.com/apache/yunikorn-core v0.0.0-20260810093905-7dc1287c839f
h1:Rxkc5C4zNgdZOnYep0j60wb9XUdDrFsdvLCuDOZ4atg=
+github.com/apache/yunikorn-core v0.0.0-20260810093905-7dc1287c839f/go.mod
h1:431ZW8b1pAkJinnDmwpmsywHKfxikvSh5PX4DhWY4zo=
github.com/apache/yunikorn-scheduler-interface
v0.0.0-20260727092410-674338955bdf
h1:IXEpAeqZgCXODJtvB6Ib5CCcpRyvF0mqcicz80rA3Cc=
github.com/apache/yunikorn-scheduler-interface
v0.0.0-20260727092410-674338955bdf/go.mod
h1:qb739Bdm82PH7gsfEYabulGF90xKGNQ1hWmf197rDfw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@@ -187,6 +187,8 @@ github.com/stretchr/testify v1.8.0/go.mod
h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod
h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1
h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod
h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
+github.com/tidwall/btree v1.8.1/go.mod
h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tidwall/gjson v1.18.0
h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod
h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
diff --git a/pkg/cache/task.go b/pkg/cache/task.go
index 795648e3..2519b69e 100644
--- a/pkg/cache/task.go
+++ b/pkg/cache/task.go
@@ -27,6 +27,8 @@ import (
"github.com/looplab/fsm"
"go.uber.org/zap"
v1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/util/wait"
+ "k8s.io/client-go/util/retry"
podutil "k8s.io/kubernetes/pkg/api/v1/pod"
"github.com/apache/yunikorn-k8shim/pkg/common"
@@ -39,6 +41,15 @@ import (
"github.com/apache/yunikorn-scheduler-interface/lib/go/si"
)
+// retryBackoff controls the retry schedule for binding pod volumes and
binding the
+// pod to a node. Declared as a package variable so tests can shorten it.
+var retryBackoff = wait.Backoff{
+ Steps: 8, // total time is 1 + 2 + 4 + 8 + 16 + 30 + 30 + 30 = 121
seconds
+ Duration: time.Second,
+ Factor: 2,
+ Cap: 30 * time.Second,
+}
+
type Task struct {
taskID string
alias string
@@ -343,46 +354,78 @@ func (task *Task) postTaskPending() {
// This routine binds the pod to the allocated node.
// It calls K8s api to bind a pod to the assigned node, this may need some
time,
// so we do a delay binding, background process, to avoid blocking main
process.
-// The result of the binding is tracked and failures are properly handled.
-// If successful, we move task to next state BOUND, otherwise we fail the task
+// Volume binding and pod binding are retried with a backoff; if they
ultimately fail
+// the allocation is rolled back to a pending ask so the core can re-schedule
the task
+// on a different node. On success we move the task to the next state BOUND.
func (task *Task) postTaskAllocated() {
- go func() {
- // we need to obtain task's lock first,
- // this ensures no other threads modifying task state at the
time being
- task.lock.Lock()
- defer task.lock.Unlock()
-
- // post a message to indicate the pod gets its allocation
- events.GetRecorder().Eventf(task.pod.DeepCopy(),
- nil, v1.EventTypeNormal, "Scheduled", "Scheduled",
- "Successfully assigned %s to node %s", task.alias,
task.nodeName)
+ // Snapshot the fields needed for binding before launching the
goroutine without re-acquiring the lock.
+ // We already hold task.lock (via task.handle()) during state
transitions.
+ pod := task.pod
+ alias := task.alias
+ nodeName := task.nodeName
+ allocationKey := task.allocationKey
+ go func(pod *v1.Pod, alias, nodeName, allocationKey string) {
+ // this lock is to protect the task from being modified while
we are binding the pod to node
+ // once all task related operations are done, release the lock
+ // This is important specially while calling
rollbackAllocation, which needs to acquire the context lock, so we cannot hold
the task lock while calling it.
+ task.lock.Lock()
// before binding pod to node, first bind volumes to pod
log.Log(log.ShimCacheTask).Debug("bind pod volumes",
- zap.String("podName", task.pod.Name),
- zap.String("podUID", string(task.pod.UID)))
- if err := task.context.bindPodVolumes(task.pod); err != nil {
- log.Log(log.ShimCacheTask).Error("bind volumes to pod
failed", zap.String("taskID", task.taskID), zap.Error(err))
- task.failWithEvent(fmt.Sprintf("bind volumes to pod
failed, name: %s, %s", task.alias, err.Error()), "PodVolumesBindFailure")
+ zap.String("podName", pod.Name),
+ zap.String("podUID", string(pod.UID)))
+ if err := retry.OnError(retryBackoff, func(err error) bool {
+ if strings.HasPrefix(err.Error(), "binding volumes:") {
+ log.Log(log.ShimCacheTask).Warn("bind volumes
to pod failed due to volume binding error, stopping retries",
+ zap.String("taskID", task.taskID),
zap.Error(err))
+ return false
+ }
+ log.Log(log.ShimCacheTask).Error("bind volumes to pod
failed, retrying",
+ zap.String("taskID", task.taskID),
zap.Error(err))
+ return true
+ }, func() error {
+ return task.context.bindPodVolumes(pod)
+ }); err != nil {
+ log.Log(log.ShimCacheTask).Error("bind volumes to pod
failed after retries",
+ zap.String("taskID", task.taskID),
zap.Error(err))
+ // release task lock before calling
rescheduleOnBindFailure to avoid deadlock
+ task.lock.Unlock()
+ task.rescheduleOnBindFailure(allocationKey, nodeName,
"PodVolumesBindFailure",
+ fmt.Sprintf("Failed to bind volumes for %s on
node %s, it will be retried", alias, nodeName))
return
}
log.Log(log.ShimCacheTask).Debug("bind pod",
- zap.String("podName", task.pod.Name),
- zap.String("podUID", string(task.pod.UID)))
+ zap.String("podName", pod.Name),
+ zap.String("podUID", string(pod.UID)))
- if err :=
task.context.apiProvider.GetAPIs().KubeClient.Bind(task.pod, task.nodeName);
err != nil {
- log.Log(log.ShimCacheTask).Error("bind pod to node
failed", zap.String("taskID", task.taskID), zap.Error(err))
- task.failWithEvent(fmt.Sprintf("bind pod to node
failed, name: %s, %s", task.alias, err.Error()), "PodBindFailure")
+ if err := retry.OnError(retryBackoff, func(err error) bool {
+ log.Log(log.ShimCacheTask).Error("bind pod to node
failed, retrying",
+ zap.String("taskID", task.taskID),
zap.Error(err))
+ return true
+ }, func() error {
+ return
task.context.apiProvider.GetAPIs().KubeClient.Bind(pod, nodeName)
+ }); err != nil {
+ log.Log(log.ShimCacheTask).Error("bind pod to node
failed after retries",
+ zap.String("taskID", task.taskID),
zap.Error(err))
+ task.lock.Unlock()
+ task.rescheduleOnBindFailure(allocationKey, nodeName,
"PodBindFailure",
+ fmt.Sprintf("Failed to bind %s to node %s, it
will be retried", alias, nodeName))
return
}
- log.Log(log.ShimCacheTask).Info("successfully bound pod",
zap.String("podName", task.pod.Name))
- dispatcher.Dispatch(NewBindTaskEvent(task.applicationID,
task.taskID))
- events.GetRecorder().Eventf(task.pod.DeepCopy(), nil,
- v1.EventTypeNormal, "PodBindSuccessful",
"PodBindSuccessful",
- "Pod %s is successfully bound to node %s", task.alias,
task.nodeName)
+ // post a message to indicate the pod gets its allocation
+ events.GetRecorder().Eventf(pod.DeepCopy(),
+ nil, v1.EventTypeNormal, "Scheduled", "Scheduled",
+ "Successfully assigned %s to node %s", alias, nodeName)
+ log.Log(log.ShimCacheTask).Info("successfully bound pod",
zap.String("podName", pod.Name))
task.schedulingState = TaskSchedAllocated
- }()
+
+ dispatcher.Dispatch(NewBindTaskEvent(task.applicationID,
task.taskID))
+ task.lock.Unlock()
+ events.GetRecorder().Eventf(pod.DeepCopy(), nil,
+ v1.EventTypeNormal, "PodBindSuccessful",
"PodBindSuccessful",
+ "Pod %s is successfully bound to node %s", alias,
nodeName)
+ }(pod, alias, nodeName, allocationKey)
}
// beforeTaskAllocated is called before handling the TaskAllocated event.
@@ -627,21 +670,26 @@ func (task *Task) failWithEvent(errorMessage,
actionReason string) {
// pending ask so it can be re-scheduled on a different node.
// Must be called without holding the task lock.
func (task *Task) rollbackOnAssumePodFailure(allocationKey, nodeID string) {
- // Read fields needed for event posting and release request.
- // Clear stale node assignment under write lock so the task is clean
for the next allocation.
task.lock.Lock()
- podCopy := task.pod.DeepCopy()
alias := task.alias
+ podCopy := task.pod.DeepCopy()
appID := task.applicationID
partition := task.application.partition
task.allocationKey = ""
task.nodeName = ""
task.lock.Unlock()
+ task.rollbackAllocation(podCopy, appID, partition, allocationKey,
nodeID, "AssumePodFailed",
+ fmt.Sprintf("Node assignment failed for %s on node %s, it will
be retried", alias, nodeID))
+}
+
+// rollbackAllocation resets the task allocation state and notifies the core
to move
+// the allocation back to a pending ask so it can be re-scheduled on a
different node.
+// Must be called without holding the task lock.
+func (task *Task) rollbackAllocation(podCopy *v1.Pod, appID, partition,
allocationKey, nodeID, eventReason, eventMsg string) {
// Post a warning event so operators can see the retry via kubectl
describe pod.
events.GetRecorder().Eventf(podCopy, nil,
- v1.EventTypeWarning, "AssumePodFailed", "AssumePodFailed",
- "Node assignment failed for %s on node %s, it will be retried",
alias, nodeID)
+ v1.EventTypeWarning, eventReason, eventReason, eventMsg)
// Revert any PV/PVC assumptions made by the volume binder. Idempotent:
safe to call
// even if AssumePodVolumes was never reached or already cleaned up
internally.
@@ -671,6 +719,31 @@ func (task *Task)
rollbackOnAssumePodFailure(allocationKey, nodeID string) {
zap.String("allocationKey", allocationKey))
}
+// rescheduleOnBindFailure is called when volume or pod binding fails after
all retries.
+// Move the task back to Scheduling before rolling back the allocation, so a
+// subsequent TaskAllocated event from the core (which requires Scheduling) is
accepted.
+// Must be called without holding the task lock as rollbackAllocation uses
context lock.
+func (task *Task) rescheduleOnBindFailure(allocationKey, nodeID, eventReason,
eventMsg string) {
+ task.lock.Lock()
+ // Move the task back to Scheduling before releasing to the core, so
the re-delivered
+ // allocation (valid only from the Scheduling state) is accepted by the
state machine.
+ if task.sm.Current() != TaskStates().Scheduling {
+ log.Log(log.ShimCacheTask).Info("task is not in Scheduling
state, moving it back to Scheduling",
+ zap.String("appID", task.applicationID),
+ zap.String("taskID", task.taskID),
+ zap.String("currentState", task.sm.Current()))
+ task.sm.SetState(TaskStates().Scheduling)
+ }
+ podCopy := task.pod.DeepCopy()
+ appID := task.applicationID
+ partition := task.application.partition
+ task.allocationKey = ""
+ task.nodeName = ""
+ task.lock.Unlock()
+
+ task.rollbackAllocation(podCopy, appID, partition, allocationKey,
nodeID, eventReason, eventMsg)
+}
+
func (task *Task) SetTaskPod(pod *v1.Pod) {
task.lock.Lock()
defer task.lock.Unlock()
diff --git a/pkg/cache/task_test.go b/pkg/cache/task_test.go
index 98184b2b..f04f1751 100644
--- a/pkg/cache/task_test.go
+++ b/pkg/cache/task_test.go
@@ -30,12 +30,17 @@ import (
schedulingv1 "k8s.io/api/scheduling/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/util/wait"
k8sEvents "k8s.io/client-go/tools/events"
+ "k8s.io/kubernetes/pkg/scheduler/framework/plugins/volumebinding"
"github.com/apache/yunikorn-k8shim/pkg/client"
"github.com/apache/yunikorn-k8shim/pkg/common/constants"
"github.com/apache/yunikorn-k8shim/pkg/common/events"
+ "github.com/apache/yunikorn-k8shim/pkg/common/test"
"github.com/apache/yunikorn-k8shim/pkg/common/utils"
+ "github.com/apache/yunikorn-k8shim/pkg/dispatcher"
"github.com/apache/yunikorn-k8shim/pkg/locking"
"github.com/apache/yunikorn-scheduler-interface/lib/go/si"
)
@@ -1023,3 +1028,228 @@ func TestRollbackOnAssumePodFailure_NilSchedulerAPI(t
*testing.T) {
assert.Equal(t, "", task.GetAllocationKey(), "allocationKey should be
cleared even with nil SchedulerAPI")
assert.Equal(t, "", task.GetNodeName(), "nodeName should be cleared
even with nil SchedulerAPI")
}
+
+// newBindTestPod returns a minimal pod usable for exercising the bind path.
+func newBindTestPod(name, uid string) *v1.Pod {
+ return &v1.Pod{
+ TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"},
+ ObjectMeta: metav1.ObjectMeta{Name: name, UID: types.UID(uid)},
+ }
+}
+
+// setShortBindBackoff shrinks the bind retry backoff so tests do not wait for
the
+// production schedule, and returns a restore function.
+func setShortBindBackoff(steps int) func() {
+ original := retryBackoff
+ retryBackoff = wait.Backoff{Steps: steps, Duration: time.Millisecond}
+ return func() { retryBackoff = original }
+}
+
+// TestRescheduleOnBindFailure_ClearsStateAndReschedules verifies that a bind
failure
+// clears the allocation, moves the task back to Scheduling, and sends a
+// SCHEDULING_FAILED_ON_RM release so the core can re-schedule on a different
node.
+func TestRescheduleOnBindFailure_ClearsStateAndReschedules(t *testing.T) {
+ mockedContext, apiProvider := initContextAndAPIProviderForTest()
+ recorder := k8sEvents.NewFakeRecorder(1024)
+ events.SetRecorder(recorder)
+ defer events.SetRecorder(events.NewMockedRecorder())
+
+ var rollbackSent atomic.Bool
+ apiProvider.MockSchedulerAPIUpdateAllocationFn(func(request
*si.AllocationRequest) error {
+ if request.Releases != nil {
+ for _, rel := range
request.Releases.AllocationsToRelease {
+ if rel.TerminationType ==
si.TerminationType_SCHEDULING_FAILED_ON_RM &&
+ rel.AllocationKey == taskUID1 {
+ rollbackSent.Store(true)
+ }
+ }
+ }
+ return nil
+ })
+
+ task := newRollbackTask(mockedContext, taskUID1, fakeNodeName)
+ task.sm.SetState(TaskStates().Allocated)
+ task.rescheduleOnBindFailure(taskUID1, fakeNodeName, "PodBindFailure",
"bind failed, it will be retried")
+
+ assert.Equal(t, TaskStates().Scheduling, task.GetTaskState(), "task
should be back in Scheduling after bind failure")
+ assert.Equal(t, "", task.GetAllocationKey(), "allocationKey should be
cleared")
+ assert.Equal(t, "", task.GetNodeName(), "nodeName should be cleared")
+ assert.Assert(t, rollbackSent.Load(), "SCHEDULING_FAILED_ON_RM release
request should be sent")
+ assert.Assert(t, len(recorder.Events) >= 1, "a bind failure event
should be posted")
+}
+
+// TestPostTaskAllocated_BindRetrySucceeds verifies that a transient pod bind
failure is
+// retried and, once it succeeds, the task stays Allocated without being
rolled back.
+func TestPostTaskAllocated_BindRetrySucceeds(t *testing.T) {
+ mockedContext, apiProvider := initContextAndAPIProviderForTest()
+ events.SetRecorder(events.NewMockedRecorder())
+ defer events.SetRecorder(events.NewMockedRecorder())
+ defer setShortBindBackoff(5)()
+
+ var bindCalls atomic.Int32
+ apiProvider.MockBindFn(func(_ *v1.Pod, _ string) error {
+ if bindCalls.Add(1) < 3 {
+ return fmt.Errorf("transient bind error")
+ }
+ return nil
+ })
+
+ app := NewApplication(appID1, queueNameA, testUser, testGroups,
map[string]string{},
+ apiProvider.GetAPIs().SchedulerAPI)
+ task := NewTask(taskUID1, app, mockedContext,
newBindTestPod("bind-retry-pod", "bind-retry-uid"))
+ task.sm.SetState(TaskStates().Scheduling)
+
+ err := task.handle(NewAllocateTaskEvent(app.applicationID, task.taskID,
taskUID1, fakeNodeName))
+ assert.NilError(t, err, "failed to handle AllocateTask event")
+
+ err = utils.WaitForCondition(func() bool {
+ return bindCalls.Load() == 3 && task.GetTaskSchedulingState()
== TaskSchedAllocated
+ }, 10*time.Millisecond, 3*time.Second)
+ assert.NilError(t, err, "pod bind did not succeed after retries")
+ assert.Equal(t, TaskStates().Allocated, task.GetTaskState(), "task
should remain Allocated after a successful bind")
+ assert.Equal(t, fakeNodeName, task.GetNodeName(), "node assignment
should be kept after a successful bind")
+}
+
+// TestPostTaskAllocated_BindExhaustedReschedules verifies that when pod
binding keeps
+// failing, the allocation is rolled back and the task returns to Scheduling.
+func TestPostTaskAllocated_BindExhaustedReschedules(t *testing.T) {
+ mockedContext, apiProvider := initContextAndAPIProviderForTest()
+ events.SetRecorder(events.NewMockedRecorder())
+ defer events.SetRecorder(events.NewMockedRecorder())
+ defer setShortBindBackoff(2)()
+
+ apiProvider.MockBindFn(func(_ *v1.Pod, _ string) error {
+ return fmt.Errorf("permanent bind error")
+ })
+
+ var rollbackSent atomic.Bool
+ apiProvider.MockSchedulerAPIUpdateAllocationFn(func(request
*si.AllocationRequest) error {
+ if request.Releases != nil {
+ for _, rel := range
request.Releases.AllocationsToRelease {
+ if rel.TerminationType ==
si.TerminationType_SCHEDULING_FAILED_ON_RM {
+ rollbackSent.Store(true)
+ }
+ }
+ }
+ return nil
+ })
+
+ app := NewApplication(appID1, queueNameA, testUser, testGroups,
map[string]string{},
+ apiProvider.GetAPIs().SchedulerAPI)
+ task := NewTask(taskUID1, app, mockedContext,
newBindTestPod("bind-fail-pod", "bind-fail-uid"))
+ task.sm.SetState(TaskStates().Scheduling)
+
+ err := task.handle(NewAllocateTaskEvent(app.applicationID, task.taskID,
taskUID1, fakeNodeName))
+ assert.NilError(t, err, "failed to handle AllocateTask event")
+
+ err = utils.WaitForCondition(func() bool {
+ return task.GetTaskState() == TaskStates().Scheduling
+ }, 10*time.Millisecond, 3*time.Second)
+ assert.NilError(t, err, "task should be moved back to Scheduling after
bind exhaustion")
+ assert.Equal(t, "", task.GetAllocationKey(), "allocationKey should be
cleared after rollback")
+ assert.Equal(t, "", task.GetNodeName(), "nodeName should be cleared
after rollback")
+ assert.Assert(t, rollbackSent.Load(), "SCHEDULING_FAILED_ON_RM release
request should be sent")
+}
+
+// TestPostTaskAllocated_VolumeBindExhaustedReschedules verifies that when
volume binding
+// keeps failing, the allocation is rolled back and the task returns to
Scheduling.
+func TestPostTaskAllocated_VolumeBindExhaustedReschedules(t *testing.T) {
+ binder := test.NewVolumeBinderMock()
+ binder.SetAllBound(false)
+ binder.SetPodVolumes(&volumebinding.PodVolumes{})
+ binder.EnableBindPodVolumesError("permanent volume bind error")
+ mockedContext := initAssumePodTest(binder)
+ defer dispatcher.UnregisterAllEventHandlers()
+ defer dispatcher.Stop()
+ defer setShortBindBackoff(2)()
+
+ apiProvider := mockedContext.apiProvider.(*client.MockedAPIProvider)
//nolint:errcheck
+ var rollbackSent atomic.Bool
+ apiProvider.MockSchedulerAPIUpdateAllocationFn(func(request
*si.AllocationRequest) error {
+ if request.Releases != nil {
+ for _, rel := range
request.Releases.AllocationsToRelease {
+ if rel.TerminationType ==
si.TerminationType_SCHEDULING_FAILED_ON_RM {
+ rollbackSent.Store(true)
+ }
+ }
+ }
+ return nil
+ })
+
+ // assume the pod so it is present in the scheduler cache with volumes
not fully bound
+ err := mockedContext.AssumePod(pod1UID, fakeNodeName)
+ assert.NilError(t, err, "failed to assume pod")
+
+ app := NewApplication(appID, queue, testUser, testGroups,
map[string]string{},
+ apiProvider.GetAPIs().SchedulerAPI)
+ task := NewTask(pod1UID, app, mockedContext, newBindTestPod(podName1,
pod1UID))
+ task.sm.SetState(TaskStates().Scheduling)
+
+ err = task.handle(NewAllocateTaskEvent(app.applicationID, task.taskID,
pod1UID, fakeNodeName))
+ assert.NilError(t, err, "failed to handle AllocateTask event")
+
+ err = utils.WaitForCondition(func() bool {
+ return task.GetTaskState() == TaskStates().Scheduling
+ }, 10*time.Millisecond, 3*time.Second)
+ assert.NilError(t, err, "task should be moved back to Scheduling after
volume bind exhaustion")
+ assert.Equal(t, "", task.GetAllocationKey(), "allocationKey should be
cleared after rollback")
+ assert.Equal(t, "", task.GetNodeName(), "nodeName should be cleared
after rollback")
+ assert.Assert(t, rollbackSent.Load(), "SCHEDULING_FAILED_ON_RM release
request should be sent")
+}
+
+// TestPostTaskAllocated_VolumeBindNoRetryOnBindingVolumesError verifies that
when volume binding
+// fails with a "binding volumes:" error, retries are stopped immediately and
the allocation is rolled back.
+func TestPostTaskAllocated_VolumeBindNoRetryOnBindingVolumesError(t
*testing.T) {
+ binder := test.NewVolumeBinderMock()
+ binder.SetAllBound(false)
+ binder.SetPodVolumes(&volumebinding.PodVolumes{})
+ binder.EnableBindPodVolumesError("binding volumes: timed out waiting
for volume binding")
+ mockedContext := initAssumePodTest(binder)
+ defer dispatcher.UnregisterAllEventHandlers()
+ defer dispatcher.Stop()
+
+ // Using a long backoff to prove it doesn't wait for retries
+ origBackoff := retryBackoff
+ retryBackoff = wait.Backoff{
+ Steps: 5,
+ Duration: 10 * time.Second,
+ Factor: 2,
+ }
+ defer func() {
+ retryBackoff = origBackoff
+ }()
+
+ apiProvider := mockedContext.apiProvider.(*client.MockedAPIProvider)
//nolint:errcheck
+ var rollbackSent atomic.Bool
+ apiProvider.MockSchedulerAPIUpdateAllocationFn(func(request
*si.AllocationRequest) error {
+ if request.Releases != nil {
+ for _, rel := range
request.Releases.AllocationsToRelease {
+ if rel.TerminationType ==
si.TerminationType_SCHEDULING_FAILED_ON_RM {
+ rollbackSent.Store(true)
+ }
+ }
+ }
+ return nil
+ })
+
+ // assume the pod so it is present in the scheduler cache with volumes
not fully bound
+ err := mockedContext.AssumePod(pod1UID, fakeNodeName)
+ assert.NilError(t, err, "failed to assume pod")
+
+ app := NewApplication(appID, queue, testUser, testGroups,
map[string]string{},
+ apiProvider.GetAPIs().SchedulerAPI)
+ task := NewTask(pod1UID, app, mockedContext, newBindTestPod(podName1,
pod1UID))
+ task.sm.SetState(TaskStates().Scheduling)
+
+ err = task.handle(NewAllocateTaskEvent(app.applicationID, task.taskID,
pod1UID, fakeNodeName))
+ assert.NilError(t, err, "failed to handle AllocateTask event")
+
+ err = utils.WaitForCondition(func() bool {
+ return task.GetTaskState() == TaskStates().Scheduling
+ }, 10*time.Millisecond, 3*time.Second)
+ assert.NilError(t, err, "task should be moved back to Scheduling
without retrying for binding volumes error")
+ assert.Equal(t, int32(1), binder.GetBindCount(), "bindPodVolumes should
only be called once when error starts with 'binding volumes:'")
+ assert.Equal(t, "", task.GetAllocationKey(), "allocationKey should be
cleared after rollback")
+ assert.Equal(t, "", task.GetNodeName(), "nodeName should be cleared
after rollback")
+ assert.Assert(t, rollbackSent.Load(), "SCHEDULING_FAILED_ON_RM release
request should be sent")
+}
diff --git a/pkg/common/test/volumebinder_mock.go
b/pkg/common/test/volumebinder_mock.go
index db2e6479..607d24e4 100644
--- a/pkg/common/test/volumebinder_mock.go
+++ b/pkg/common/test/volumebinder_mock.go
@@ -21,6 +21,7 @@ package test
import (
"context"
"errors"
+ "sync/atomic"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/sets"
@@ -41,6 +42,7 @@ type VolumeBinderMock struct {
podVolumes *volumebinding.PodVolumes
allBound bool
revertCalledCount int
+ bindCount atomic.Int32
}
func NewVolumeBinderMock() *VolumeBinderMock {
@@ -94,9 +96,14 @@ func (v *VolumeBinderMock) SetPodVolumes(podVolumes
*volumebinding.PodVolumes) {
}
func (v *VolumeBinderMock) BindPodVolumes(_ context.Context, _ *v1.Pod, _
*volumebinding.PodVolumes) error {
+ v.bindCount.Add(1)
return v.bindError
}
+func (v *VolumeBinderMock) GetBindCount() int32 {
+ return v.bindCount.Load()
+}
+
func (v *VolumeBinderMock) EnableVolumeClaimsError(message string) {
v.volumeClaimError = errors.New(message)
}
@@ -105,6 +112,10 @@ func (v *VolumeBinderMock)
EnableFindPodVolumesError(message string) {
v.findPodVolumesError = errors.New(message)
}
+func (v *VolumeBinderMock) EnableBindPodVolumesError(message string) {
+ v.bindError = errors.New(message)
+}
+
func (v *VolumeBinderMock) SetConflictReasons(reasons ...string) {
var conflicts []volumebinding.ConflictReason
for _, r := range reasons {
diff --git a/pkg/shim/scheduler_test.go b/pkg/shim/scheduler_test.go
index 10b68d42..145cabc1 100644
--- a/pkg/shim/scheduler_test.go
+++ b/pkg/shim/scheduler_test.go
@@ -20,6 +20,7 @@ package shim
import (
"fmt"
+ "sync/atomic"
"testing"
"time"
@@ -184,10 +185,13 @@ func TestTaskFailures(t *testing.T) {
assert.NilError(t, cluster.start(), "failed to start cluster")
defer cluster.stop()
- // mock pod bind failures
+ // mock a transient pod bind failure: task0001 fails its first two bind
attempts and
+ // then succeeds. With the bind retry mechanism the task must recover
and bind rather
+ // than failing.
+ var task1BindAttempts atomic.Int32
cluster.apiProvider.MockBindFn(func(pod *v1.Pod, hostID string) error {
- if pod.Name == "task0001" {
- return fmt.Errorf("mocked error when binding the pod")
+ if pod.Name == "task0001" && task1BindAttempts.Add(1) <= 2 {
+ return fmt.Errorf("mocked transient error when binding
the pod")
}
return nil
})
@@ -216,15 +220,16 @@ func TestTaskFailures(t *testing.T) {
cluster.AddPod(task2)
// wait for scheduling app and tasks
- // verify app state
+ // both tasks must end up bound: task0002 directly, task0001 after its
bind is retried
cluster.waitAndAssertApplicationState(t, "app0001",
cache.ApplicationStates().Running)
- cluster.waitAndAssertTaskState(t, "app0001", "task0001",
cache.TaskStates().Failed)
+ cluster.waitAndAssertTaskState(t, "app0001", "task0001",
cache.TaskStates().Bound)
cluster.waitAndAssertTaskState(t, "app0001", "task0002",
cache.TaskStates().Bound)
- // one task get bound, one ask failed, so we are expecting only 1
allocation in the scheduler
+ // both tasks get bound, so we are expecting 2 allocations in the
scheduler
err = cluster.waitAndVerifySchedulerAllocations("root.a",
- "[mycluster]default", "app0001", 1)
+ "[mycluster]default", "app0001", 2)
assert.NilError(t, err, "number of allocations is not expected, error")
+ assert.Assert(t, task1BindAttempts.Load() >= 3, "task0001 bind should
have been retried before succeeding")
}
// simulate PVC error during Context.AssumePod() call
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]