Skip to content

Commit 5b5ee96

Browse files
committed
feat: support Kubernetes Coscheduling Plugin for PodGroup gang scheduling
- Retry LockNode with backoff on lock contention to allow concurrent PodGroup bind operations to serialize gracefully - Evict pending pods with stale annotations in onAddPod to prevent phantom GPU occupancy when Coscheduling denies a PodGroup - Call podManager.DelPod in Bind failure path to prevent ghost cache entries on failed binds - Add --node-lock-retry-timeout flag (default: 30s) Signed-off-by: lin121291 <4jp33f9e@gmail.com>
1 parent 31a2792 commit 5b5ee96

5 files changed

Lines changed: 191 additions & 13 deletions

File tree

cmd/scheduler/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ func init() {
8080
rootCmd.Flags().IntVar(&config.Timeout, "kube-timeout", client.DefaultTimeout, "Timeout to use while talking with kube-apiserver.")
8181
rootCmd.Flags().BoolVar(&enableProfiling, "profiling", false, "Enable pprof profiling via HTTP server")
8282
rootCmd.Flags().DurationVar(&config.NodeLockTimeout, "node-lock-timeout", time.Minute*5, "timeout for node locks")
83+
rootCmd.Flags().DurationVar(&config.NodeLockRetryTimeout, "node-lock-retry-timeout", time.Second*30, "timeout for retrying node lock acquisition when locked by valid pod")
8384
rootCmd.Flags().BoolVar(&config.ForceOverwriteDefaultScheduler, "force-overwrite-default-scheduler", true, "Overwrite schedulerName in Pod Spec when set to the const DefaultSchedulerName in https://k8s.io/api/core/v1 package")
8485

8586
rootCmd.Flags().BoolVar(&config.LeaderElect, "leader-elect", false, "The pod of hami-scheduler enable leader select")
@@ -115,6 +116,8 @@ func start() error {
115116
// Initialize node lock timeout from config
116117
nodelock.NodeLockTimeout = config.NodeLockTimeout
117118
klog.InfoS("Set node lock timeout", "timeout", nodelock.NodeLockTimeout)
119+
nodelock.NodeLockRetryTimeout = config.NodeLockRetryTimeout
120+
klog.InfoS("Set node lock retry timeout", "timeout", nodelock.NodeLockRetryTimeout)
118121
client.InitGlobalClient(
119122
client.WithBurst(config.Burst),
120123
client.WithQPS(config.QPS),

pkg/scheduler/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ var (
6363
// NodeLockTimeout is the timeout for node locks.
6464
NodeLockTimeout time.Duration
6565

66+
// NodeLockRetryTimeout is the timeout for retrying node lock acquisition.
67+
NodeLockRetryTimeout time.Duration
68+
6669
// If set to false, When Pod.Spec.SchedulerName equals to the const DefaultSchedulerName in k8s.io/api/core/v1 package, webhook will not overwrite it, default value is true.
6770
ForceOverwriteDefaultScheduler bool
6871

pkg/scheduler/scheduler.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ func (s *Scheduler) onAddPod(obj any) {
157157
s.podManager.UpdatePod(pod)
158158
return
159159
}
160+
// When Coscheduling denies a PodGroup, pods return to Pending with stale
161+
// HAMi annotations. Evict them from cache to prevent phantom GPU occupancy.
162+
if pod.Status.Phase == corev1.PodPending && pod.Annotations[util.DeviceBindPhase] != util.DeviceBindSuccess {
163+
klog.V(5).InfoS("Pod is pending with stale annotations, evicting from cache", "pod", pod.Name)
164+
s.podManager.DelPod(pod)
165+
return
166+
}
160167
podDev, _ := device.DecodePodDevices(device.SupportDevices, pod.Annotations)
161168
if s.podManager.AddPod(pod, nodeID, podDev) {
162169
s.quotaManager.AddUsage(pod, podDev)
@@ -717,6 +724,7 @@ ReleaseNodeLocks:
717724
for _, val := range device.GetDevices() {
718725
val.ReleaseNodeLock(node, current)
719726
}
727+
s.podManager.DelPod(current)
720728
s.recordScheduleBindingResultEvent(current, EventReasonBindingFailed, []string{}, err)
721729
return &extenderv1.ExtenderBindingResult{Error: err.Error()}, nil
722730
}

pkg/util/nodelock/nodelock.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package nodelock
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"os"
2324
"strings"
@@ -40,11 +41,19 @@ const (
4041
NodeLockSep = ","
4142
)
4243

44+
// errNodeLocked is a sentinel error indicating the node is locked by a valid
45+
// (non-expired, non-dangling) pod. LockNode uses this to distinguish retryable
46+
// lock contention from non-retryable errors.
47+
var errNodeLocked = errors.New("node is locked by a valid pod")
48+
4349
var (
4450
// nodeLocks manages per-node locks for fine-grained concurrency control.
4551
nodeLocks = newNodeLockManager()
4652
// NodeLockTimeout is the global timeout for node locks.
4753
NodeLockTimeout time.Duration = time.Minute * 5
54+
// NodeLockRetryTimeout is the timeout for retrying lock acquisition when
55+
// a node is locked by a valid (non-expired, non-dangling) pod.
56+
NodeLockRetryTimeout time.Duration = time.Second * 30
4857

4958
DefaultStrategy = wait.Backoff{
5059
Steps: 5,
@@ -115,6 +124,16 @@ func setupNodeLockTimeout() {
115124
klog.InfoS("Node lock expiration time set from environment variable", "duration", d)
116125
}
117126
}
127+
retryTimeout := os.Getenv("HAMI_NODELOCK_RETRY_TIMEOUT")
128+
if retryTimeout != "" {
129+
d, err := time.ParseDuration(retryTimeout)
130+
if err != nil {
131+
klog.ErrorS(err, "Failed to parse HAMI_NODELOCK_RETRY_TIMEOUT, using default", "duration", NodeLockRetryTimeout)
132+
} else {
133+
NodeLockRetryTimeout = d
134+
klog.InfoS("Node lock retry timeout set from environment variable", "duration", d)
135+
}
136+
}
118137
}
119138

120139
func SetNodeLock(nodeName string, lockname string, pods *corev1.Pod) error {
@@ -205,8 +224,39 @@ func ReleaseNodeLock(nodeName string, lockname string, pod *corev1.Pod, skipNode
205224
return nil
206225
}
207226

227+
// LockNode attempts to acquire a node lock, retrying with backoff when the
228+
// node is locked by a valid (non-expired, non-dangling) pod. This allows
229+
// concurrent bind operations (e.g., from coscheduling PodGroups) to succeed
230+
// sequentially on the same node rather than failing immediately.
208231
func LockNode(nodeName string, lockname string, pods *corev1.Pod) error {
209-
ctx := context.Background()
232+
ctx, cancel := context.WithTimeout(context.Background(), NodeLockRetryTimeout)
233+
defer cancel()
234+
235+
var lastErr error
236+
err := wait.PollUntilContextCancel(ctx, time.Second, true, func(ctx context.Context) (bool, error) {
237+
lastErr = tryLockNode(ctx, nodeName, lockname, pods)
238+
if lastErr == nil {
239+
return true, nil
240+
}
241+
if errors.Is(lastErr, errNodeLocked) {
242+
klog.V(5).InfoS("Node locked by valid pod, retrying", "node", nodeName)
243+
return false, nil
244+
}
245+
return false, lastErr
246+
})
247+
if err != nil {
248+
if lastErr != nil {
249+
return lastErr
250+
}
251+
return fmt.Errorf("failed to lock node %s: timed out after %v", nodeName, NodeLockRetryTimeout)
252+
}
253+
return nil
254+
}
255+
256+
// tryLockNode makes a single attempt to acquire the node lock.
257+
// It returns nil on success, errNodeLocked when the lock is held by a valid
258+
// pod (retryable), or a regular error for non-retryable failures.
259+
func tryLockNode(ctx context.Context, nodeName string, lockname string, pods *corev1.Pod) error {
210260
node, err := client.GetClient().CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{})
211261
if err != nil {
212262
return err
@@ -219,13 +269,18 @@ func LockNode(nodeName string, lockname string, pods *corev1.Pod) error {
219269
return err
220270
}
221271

272+
// Lock held by the same pod → idempotent success.
273+
if ns == pods.Namespace && previousPodName == pods.Name {
274+
return nil
275+
}
276+
222277
var skipOwnerCheck = false
223278
if time.Since(lockTime) > NodeLockTimeout {
224279
klog.InfoS("Node lock expired", "node", nodeName, "lockTime", lockTime, "timeout", NodeLockTimeout)
225280
skipOwnerCheck = true
226281
} else
227282
// Check dangling nodeLock
228-
if ns != "" && previousPodName != "" && (ns != pods.Namespace || previousPodName != pods.Name) {
283+
if ns != "" && previousPodName != "" {
229284
if _, err := client.GetClient().CoreV1().Pods(ns).Get(ctx, previousPodName, metav1.GetOptions{}); err != nil {
230285
if !apierrors.IsNotFound(err) {
231286
klog.ErrorS(err, "Failed to get pod of NodeLock", "podName", previousPodName, "namespace", ns)
@@ -245,7 +300,7 @@ func LockNode(nodeName string, lockname string, pods *corev1.Pod) error {
245300
return SetNodeLock(nodeName, lockname, pods)
246301
}
247302

248-
return fmt.Errorf("node %s has been locked within %v", nodeName, NodeLockTimeout)
303+
return errNodeLocked
249304
}
250305

251306
func ParseNodeLock(value string) (lockTime time.Time, ns, name string, err error) {

pkg/util/nodelock/nodelock_test.go

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ import (
3232

3333
func Test_LockNode(t *testing.T) {
3434
client.KubeClient = fake.NewClientset()
35+
36+
// Use a short retry timeout so tests that expect lock contention complete quickly.
37+
originalRetryTimeout := NodeLockRetryTimeout
38+
NodeLockRetryTimeout = 500 * time.Millisecond
39+
defer func() {
40+
NodeLockRetryTimeout = originalRetryTimeout
41+
}()
42+
3543
type args struct {
3644
nodeName func() string
3745
lockname string
@@ -58,7 +66,7 @@ func Test_LockNode(t *testing.T) {
5866
wantErr: true,
5967
},
6068
{
61-
name: "node has been locked",
69+
name: "node has been locked by another pod",
6270
args: args{
6371
nodeName: func() string {
6472
name := "worker-1"
@@ -67,11 +75,15 @@ func Test_LockNode(t *testing.T) {
6775
Name: name,
6876
Annotations: map[string]string{
6977
NodeLockKey: GenerateNodeLockKeyByPod(&corev1.Pod{
70-
ObjectMeta: metav1.ObjectMeta{Name: "hami", Namespace: "hami-ns"},
78+
ObjectMeta: metav1.ObjectMeta{Name: "other-pod", Namespace: "other-ns"},
7179
}),
7280
},
7381
},
7482
}, metav1.CreateOptions{})
83+
// The lock-holding pod must exist to avoid dangling detection.
84+
client.KubeClient.CoreV1().Pods("other-ns").Create(context.TODO(), &corev1.Pod{
85+
ObjectMeta: metav1.ObjectMeta{Name: "other-pod", Namespace: "other-ns"},
86+
}, metav1.CreateOptions{})
7587
return name
7688
},
7789
pods: &corev1.Pod{
@@ -139,16 +151,23 @@ func Test_LockNode(t *testing.T) {
139151
func TestLockNodeWithTimeout(t *testing.T) {
140152
client.KubeClient = fake.NewClientset()
141153

142-
// Set a custom timeout for testing
154+
// Set a custom timeout for testing.
143155
originalTimeout := NodeLockTimeout
144156
NodeLockTimeout = time.Minute * 2
145157
defer func() {
146158
NodeLockTimeout = originalTimeout
147159
}()
148160

161+
// Use a short retry timeout so the test completes quickly.
162+
originalRetryTimeout := NodeLockRetryTimeout
163+
NodeLockRetryTimeout = 500 * time.Millisecond
164+
defer func() {
165+
NodeLockRetryTimeout = originalRetryTimeout
166+
}()
167+
149168
nodeName := "test-node-timeout"
150169

151-
// Create a node with a fresh lock (should not be expired)
170+
// Create a node with a fresh lock (should not be expired).
152171
freshLockTime := time.Now().Format(time.RFC3339)
153172
testNamespace := "test-ns"
154173
testPodName := "test-pod"
@@ -163,7 +182,7 @@ func TestLockNodeWithTimeout(t *testing.T) {
163182
},
164183
}, metav1.CreateOptions{})
165184

166-
// Pod must exist to avoid dangling node lock
185+
// Pod must exist to avoid dangling node lock.
167186
client.KubeClient.CoreV1().Pods(testNamespace).Create(context.TODO(), &corev1.Pod{
168187
ObjectMeta: metav1.ObjectMeta{
169188
Name: testPodName,
@@ -178,17 +197,21 @@ func TestLockNodeWithTimeout(t *testing.T) {
178197
},
179198
}
180199

181-
// Try to lock the node again - this should trigger line 130
200+
start := time.Now()
182201
err := LockNode(nodeName, "", pod)
202+
elapsed := time.Since(start)
183203

184-
// Verify the error contains the NodeLockTimeout value
185204
if err == nil {
186205
t.Fatal("Expected error but got nil")
187206
}
188207

189-
expectedError := "has been locked within 2m0s"
190-
if !strings.Contains(err.Error(), expectedError) {
191-
t.Errorf("Expected error to contain '%s', but got: %v", expectedError, err)
208+
// Should have retried for roughly NodeLockRetryTimeout before giving up.
209+
if elapsed < 400*time.Millisecond {
210+
t.Errorf("LockNode returned too quickly (%v), expected retry for ~%v", elapsed, NodeLockRetryTimeout)
211+
}
212+
213+
if !strings.Contains(err.Error(), "locked by a valid pod") {
214+
t.Errorf("Expected errNodeLocked sentinel, got: %v", err)
192215
}
193216
}
194217

@@ -501,6 +524,92 @@ func TestGeneratePodNamespaceName(t *testing.T) {
501524
}
502525
}
503526

527+
// TestLockNodeRetrySuccess verifies that LockNode retries and succeeds when a
528+
// lock is released by the holder before the retry timeout expires.
529+
func TestLockNodeRetrySuccess(t *testing.T) {
530+
client.KubeClient = fake.NewClientset()
531+
nodeLocks = newNodeLockManager()
532+
533+
originalRetryTimeout := NodeLockRetryTimeout
534+
NodeLockRetryTimeout = 5 * time.Second
535+
defer func() {
536+
NodeLockRetryTimeout = originalRetryTimeout
537+
}()
538+
539+
nodeName := "retry-success-node"
540+
holderPod := &corev1.Pod{
541+
ObjectMeta: metav1.ObjectMeta{Name: "holder-pod", Namespace: "holder-ns"},
542+
}
543+
waiterPod := &corev1.Pod{
544+
ObjectMeta: metav1.ObjectMeta{Name: "waiter-pod", Namespace: "waiter-ns"},
545+
}
546+
547+
// Create node and holder pod.
548+
client.KubeClient.CoreV1().Nodes().Create(context.TODO(), &corev1.Node{
549+
ObjectMeta: metav1.ObjectMeta{Name: nodeName, Annotations: map[string]string{}},
550+
}, metav1.CreateOptions{})
551+
client.KubeClient.CoreV1().Pods("holder-ns").Create(context.TODO(), holderPod, metav1.CreateOptions{})
552+
553+
// Holder acquires the lock.
554+
if err := LockNode(nodeName, "", holderPod); err != nil {
555+
t.Fatalf("Holder failed to acquire lock: %v", err)
556+
}
557+
558+
// Release the lock after a short delay so the waiter can acquire it.
559+
go func() {
560+
time.Sleep(500 * time.Millisecond)
561+
if err := ReleaseNodeLock(nodeName, "", holderPod, false); err != nil {
562+
t.Errorf("Holder failed to release lock: %v", err)
563+
}
564+
}()
565+
566+
// Waiter should retry and eventually succeed.
567+
start := time.Now()
568+
if err := LockNode(nodeName, "", waiterPod); err != nil {
569+
t.Fatalf("Waiter failed to acquire lock after retry: %v", err)
570+
}
571+
elapsed := time.Since(start)
572+
573+
if elapsed < 400*time.Millisecond {
574+
t.Errorf("Waiter acquired lock too quickly (%v), expected some retry delay", elapsed)
575+
}
576+
if elapsed > 3*time.Second {
577+
t.Errorf("Waiter took too long (%v), expected lock within ~1-2s", elapsed)
578+
}
579+
}
580+
581+
// TestLockNodeIdempotent verifies that LockNode succeeds immediately when the
582+
// lock is already held by the same pod (idempotent behavior).
583+
func TestLockNodeIdempotent(t *testing.T) {
584+
client.KubeClient = fake.NewClientset()
585+
nodeLocks = newNodeLockManager()
586+
587+
nodeName := "idempotent-node"
588+
pod := &corev1.Pod{
589+
ObjectMeta: metav1.ObjectMeta{Name: "my-pod", Namespace: "my-ns"},
590+
}
591+
592+
client.KubeClient.CoreV1().Nodes().Create(context.TODO(), &corev1.Node{
593+
ObjectMeta: metav1.ObjectMeta{Name: nodeName, Annotations: map[string]string{}},
594+
}, metav1.CreateOptions{})
595+
596+
// First lock.
597+
if err := LockNode(nodeName, "", pod); err != nil {
598+
t.Fatalf("First LockNode failed: %v", err)
599+
}
600+
601+
// Second lock by the same pod should succeed immediately.
602+
start := time.Now()
603+
if err := LockNode(nodeName, "", pod); err != nil {
604+
t.Fatalf("Second LockNode (idempotent) failed: %v", err)
605+
}
606+
elapsed := time.Since(start)
607+
608+
if elapsed > 200*time.Millisecond {
609+
t.Errorf("Idempotent lock took too long (%v), should be immediate", elapsed)
610+
}
611+
}
612+
504613
// TestSimulateRetryStorm verifies if the Backoff strategy is using exponential backoff.
505614
func TestSimulateRetryStorm(t *testing.T) {
506615
tests := []struct {

0 commit comments

Comments
 (0)