Skip to content

Commit 600f103

Browse files
committed
feat(fault-quarantine): replace GET + PUT with PATCH
Signed-off-by: Ajay Mishra <ajmishra@nvidia.com>
1 parent 8d92cb2 commit 600f103

5 files changed

Lines changed: 229 additions & 25 deletions

File tree

commons/pkg/kubeclient/nodepatch.go

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,25 @@ package kubeclient
1717
import (
1818
"encoding/json"
1919
"fmt"
20+
"reflect"
2021

2122
v1 "k8s.io/api/core/v1"
2223
)
2324

24-
// NodeMergePatch builds an RFC 7386 JSON merge patch carrying the label and
25-
// annotation differences between original and modified. It returns a nil patch when
26-
// the two already agree, so callers can skip the write instead of spending an API
27-
// call on a no-op.
25+
// NodeMergePatch builds an RFC 7386 JSON merge patch carrying differences in labels,
26+
// annotations, taints, and unschedulable state. It returns a nil patch when the two
27+
// nodes already agree, so callers can skip a no-op write.
2828
//
2929
// The patch is assembled key by key rather than by marshalling modified, because
3030
// marshalling a Node emits every populated field. A caller that read original from an
3131
// informer cache holding a projected Node would then patch the projection's gaps back
3232
// over the real object. Emitting only keys that differ means fields missing from both
3333
// sides are left untouched.
3434
//
35-
// Spec fields such as taints and unschedulable are deliberately out of scope: a merge
36-
// patch replaces a list wholesale, so patching taints from a projected Node whose Spec
37-
// had been cleared would silently drop every taint on the real object.
35+
// Taints are emitted only when the caller changed them. A projected Node whose Spec
36+
// is empty on both sides therefore cannot erase taints from the real object.
3837
func NodeMergePatch(original, modified *v1.Node) ([]byte, error) {
38+
root := map[string]any{}
3939
metadata := map[string]any{}
4040

4141
if labels := stringMapMergePatch(original.Labels, modified.Labels); labels != nil {
@@ -46,18 +46,49 @@ func NodeMergePatch(original, modified *v1.Node) ([]byte, error) {
4646
metadata["annotations"] = annotations
4747
}
4848

49-
if len(metadata) == 0 {
49+
if len(metadata) > 0 {
50+
root["metadata"] = metadata
51+
}
52+
53+
spec := map[string]any{}
54+
if !taintsEqual(original.Spec.Taints, modified.Spec.Taints) {
55+
spec["taints"] = modified.Spec.Taints
56+
}
57+
58+
if original.Spec.Unschedulable != modified.Spec.Unschedulable {
59+
spec["unschedulable"] = modified.Spec.Unschedulable
60+
}
61+
62+
if len(spec) > 0 {
63+
root["spec"] = spec
64+
}
65+
66+
if len(root) == 0 {
5067
return nil, nil
5168
}
5269

53-
patch, err := json.Marshal(map[string]any{"metadata": metadata})
70+
patch, err := json.Marshal(root)
5471
if err != nil {
5572
return nil, fmt.Errorf("marshal merge patch for node %s: %w", original.Name, err)
5673
}
5774

5875
return patch, nil
5976
}
6077

78+
func taintsEqual(original, modified []v1.Taint) bool {
79+
if len(original) != len(modified) {
80+
return false
81+
}
82+
83+
for idx := range original {
84+
if !reflect.DeepEqual(original[idx], modified[idx]) {
85+
return false
86+
}
87+
}
88+
89+
return true
90+
}
91+
6192
// stringMapMergePatch returns the merge patch entries that turn original into
6293
// modified: added and changed keys map to their new value, removed keys map to nil so
6394
// the API server deletes them. It returns nil when the two maps already agree.

commons/pkg/kubeclient/nodepatch_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func TestNodeMergePatch_ProjectedFields_LeavesThemAlone(t *testing.T) {
132132
"a cleared Spec must never reach the patch, or real taints would be dropped")
133133
}
134134

135-
func TestNodeMergePatch_SpecChanges_ReturnsNoPatch(t *testing.T) {
135+
func TestNodeMergePatch_SpecChanges_ReturnsExpectedPatch(t *testing.T) {
136136
original := node(nil, nil)
137137
modified := original.DeepCopy()
138138
modified.Spec.Unschedulable = true
@@ -141,5 +141,12 @@ func TestNodeMergePatch_SpecChanges_ReturnsNoPatch(t *testing.T) {
141141
patch, err := NodeMergePatch(original, modified)
142142
require.NoError(t, err)
143143

144-
assert.Nil(t, patch, "spec is out of scope until a caller needs it")
144+
assert.JSONEq(t,
145+
`{"spec":{"taints":[{"key":"held","effect":"NoSchedule"}],"unschedulable":true}}`,
146+
string(patch),
147+
)
148+
149+
patch, err = NodeMergePatch(modified, original)
150+
require.NoError(t, err)
151+
assert.JSONEq(t, `{"spec":{"taints":null,"unschedulable":false}}`, string(patch))
145152
}

fault-quarantine/pkg/informer/k8s_client.go

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
v1 "k8s.io/api/core/v1"
2828
"k8s.io/apimachinery/pkg/api/errors"
2929
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30+
"k8s.io/apimachinery/pkg/types"
3031
"k8s.io/apimachinery/pkg/util/wait"
3132
"k8s.io/client-go/kubernetes"
3233
"k8s.io/client-go/rest"
@@ -57,6 +58,7 @@ type FaultQuarantineClient struct {
5758
cordonedReasonLabelKey string
5859
uncordonedReasonLabelKey string
5960
operationMutex sync.Map // map[string]*sync.Mutex for per-node locking
61+
lastWrittenNodeVersion sync.Map // map[string]string
6062
}
6163

6264
// NewFaultQuarantineClient constructs a FaultQuarantineClient using the
@@ -171,26 +173,82 @@ func (c *FaultQuarantineClient) UpdateNode(ctx context.Context, nodeName string,
171173
}
172174

173175
return retry.OnError(backoff, isRetryableError, func() error {
174-
node, err := c.Clientset.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{})
176+
current, err := c.nodeForPatch(ctx, nodeName)
175177
if err != nil {
176178
return err
177179
}
178180

179-
if err := updateFn(node); err != nil {
181+
desired := current.DeepCopy()
182+
if err := updateFn(desired); err != nil {
180183
return err
181184
}
182185

183-
_, err = c.Clientset.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{})
186+
patch, err := kubeclient.NodeMergePatch(current, desired)
184187
if err != nil {
185188
return err
186189
}
187190

188-
slog.Debug("Updated node", "node", nodeName)
191+
if patch == nil {
192+
return nil
193+
}
194+
195+
updated, err := c.Clientset.CoreV1().Nodes().Patch(
196+
ctx,
197+
nodeName,
198+
types.MergePatchType,
199+
patch,
200+
metav1.PatchOptions{},
201+
)
202+
if err != nil {
203+
return err
204+
}
205+
206+
c.lastWrittenNodeVersion.Store(nodeName, updated.ResourceVersion)
207+
slog.Debug("Patched node", "node", nodeName)
189208

190209
return nil
191210
})
192211
}
193212

213+
func (c *FaultQuarantineClient) nodeForPatch(ctx context.Context, nodeName string) (*v1.Node, error) {
214+
if node, ready := c.cachedNodeForPatch(nodeName); ready {
215+
return node, nil
216+
}
217+
218+
// The informer may not be running in tests or may not have observed our previous
219+
// write yet. A live read preserves consecutive updates to the same node.
220+
node, err := c.Clientset.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{})
221+
if err != nil {
222+
return nil, err
223+
}
224+
225+
c.lastWrittenNodeVersion.Delete(nodeName)
226+
227+
return node, nil
228+
}
229+
230+
func (c *FaultQuarantineClient) cachedNodeForPatch(nodeName string) (*v1.Node, bool) {
231+
if c.NodeInformer == nil || !c.NodeInformer.HasSynced() {
232+
return nil, false
233+
}
234+
235+
node, err := c.NodeInformer.GetNode(nodeName)
236+
if err != nil {
237+
return nil, false
238+
}
239+
240+
lastWrittenVersion, pending := c.lastWrittenNodeVersion.Load(nodeName)
241+
if pending && node.ResourceVersion != lastWrittenVersion.(string) {
242+
return nil, false
243+
}
244+
245+
if pending {
246+
c.lastWrittenNodeVersion.Delete(nodeName)
247+
}
248+
249+
return node.DeepCopy(), true
250+
}
251+
194252
func isRetryableError(err error) bool {
195253
if errors.IsConflict(err) {
196254
return true
@@ -376,29 +434,25 @@ func (c *FaultQuarantineClient) applyTaints(
376434
return nil
377435
}
378436

379-
existingTaints := make(map[config.Taint]v1.Taint)
437+
existingTaints := make(map[config.Taint]struct{})
380438
for _, taint := range node.Spec.Taints {
381-
existingTaints[config.Taint{Key: taint.Key, Value: taint.Value, Effect: string(taint.Effect)}] = taint
439+
existingTaints[config.Taint{Key: taint.Key, Value: taint.Value, Effect: string(taint.Effect)}] = struct{}{}
382440
}
383441

384442
for _, taintConfig := range taints {
385443
key := config.Taint{Key: taintConfig.Key, Value: taintConfig.Value, Effect: string(taintConfig.Effect)}
386444

387445
if _, exists := existingTaints[key]; !exists {
388446
slog.InfoContext(ctx, "Tainting node", "node", nodename, "taintConfig", taintConfig)
389-
existingTaints[key] = v1.Taint{
447+
node.Spec.Taints = append(node.Spec.Taints, v1.Taint{
390448
Key: taintConfig.Key,
391449
Value: taintConfig.Value,
392450
Effect: v1.TaintEffect(taintConfig.Effect),
393-
}
451+
})
452+
existingTaints[key] = struct{}{}
394453
}
395454
}
396455

397-
node.Spec.Taints = []v1.Taint{}
398-
for _, taint := range existingTaints {
399-
node.Spec.Taints = append(node.Spec.Taints, taint)
400-
}
401-
402456
return nil
403457
}
404458

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package informer
16+
17+
import (
18+
"context"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
v1 "k8s.io/api/core/v1"
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
"k8s.io/apimachinery/pkg/types"
26+
"k8s.io/client-go/kubernetes/fake"
27+
corelisters "k8s.io/client-go/listers/core/v1"
28+
k8stesting "k8s.io/client-go/testing"
29+
"k8s.io/client-go/tools/cache"
30+
)
31+
32+
func cachedNodeInformer(t *testing.T, node *v1.Node) *NodeInformer {
33+
t.Helper()
34+
35+
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
36+
require.NoError(t, indexer.Add(node))
37+
38+
return &NodeInformer{
39+
lister: corelisters.NewNodeLister(indexer),
40+
informerSynced: func() bool { return true },
41+
}
42+
}
43+
44+
func TestUpdateNode_CachedNode_UsesPatchAndSkipsNoOp(t *testing.T) {
45+
node := &v1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-1", ResourceVersion: "1"}}
46+
clientset := fake.NewSimpleClientset(node.DeepCopy())
47+
client := &FaultQuarantineClient{
48+
Clientset: clientset,
49+
NodeInformer: cachedNodeInformer(t, node.DeepCopy()),
50+
}
51+
52+
clientset.ClearActions()
53+
_, err := client.QuarantineNodeAndSetAnnotations(
54+
context.Background(),
55+
node.Name,
56+
nil,
57+
true,
58+
nil,
59+
nil,
60+
)
61+
require.NoError(t, err)
62+
63+
actions := clientset.Actions()
64+
require.Len(t, actions, 1)
65+
patchAction, ok := actions[0].(k8stesting.PatchAction)
66+
require.True(t, ok)
67+
assert.Equal(t, types.MergePatchType, patchAction.GetPatchType())
68+
assert.JSONEq(t, `{"spec":{"unschedulable":true}}`, string(patchAction.GetPatch()))
69+
70+
updated, err := clientset.CoreV1().Nodes().Get(t.Context(), node.Name, metav1.GetOptions{})
71+
require.NoError(t, err)
72+
assert.True(t, updated.Spec.Unschedulable)
73+
74+
client.NodeInformer = cachedNodeInformer(t, updated.DeepCopy())
75+
clientset.ClearActions()
76+
_, err = client.QuarantineNodeAndSetAnnotations(
77+
context.Background(),
78+
node.Name,
79+
nil,
80+
true,
81+
nil,
82+
nil,
83+
)
84+
require.NoError(t, err)
85+
assert.Empty(t, clientset.Actions())
86+
}
87+
88+
func TestNodeForPatch_PreviousWriteNotInCache_FallsBackToLiveGet(t *testing.T) {
89+
cached := &v1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-1", ResourceVersion: "1"}}
90+
live := cached.DeepCopy()
91+
live.ResourceVersion = "2"
92+
live.Annotations = map[string]string{"first-write": "preserved"}
93+
94+
clientset := fake.NewSimpleClientset(live)
95+
client := &FaultQuarantineClient{
96+
Clientset: clientset,
97+
NodeInformer: cachedNodeInformer(t, cached),
98+
}
99+
client.lastWrittenNodeVersion.Store(cached.Name, live.ResourceVersion)
100+
clientset.ClearActions()
101+
102+
node, err := client.nodeForPatch(t.Context(), cached.Name)
103+
require.NoError(t, err)
104+
assert.Equal(t, live.ResourceVersion, node.ResourceVersion)
105+
assert.Equal(t, "preserved", node.Annotations["first-write"])
106+
require.Len(t, clientset.Actions(), 1)
107+
assert.Equal(t, "get", clientset.Actions()[0].GetVerb())
108+
}

fault-quarantine/pkg/informer/k8s_client_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ func measureCordonThroughput(t *testing.T, prefix string, nodeCount int, qps flo
172172
)
173173
require.NoError(t, err)
174174

175+
stopCh := make(chan struct{})
176+
t.Cleanup(func() { close(stopCh) })
177+
require.NoError(t, client.NodeInformer.Run(stopCh))
178+
175179
start := time.Now()
176180
for _, nodeName := range nodeNames {
177181
_, err := client.QuarantineNodeAndSetAnnotations(ctx, nodeName, nil, true, nil, nil)
@@ -182,7 +186,7 @@ func measureCordonThroughput(t *testing.T, prefix string, nodeCount int, qps flo
182186
}
183187

184188
// TestQuarantineNodeAndSetAnnotations_QPSControlledCordonThroughput_HigherQPSIncreasesThroughput
185-
// exercises FQ's real GET+UPDATE cordon path against envtest.
189+
// exercises FQ's cache-read + PATCH cordon path against envtest.
186190
func TestQuarantineNodeAndSetAnnotations_QPSControlledCordonThroughput_HigherQPSIncreasesThroughput(t *testing.T) {
187191
const (
188192
nodeCount = 10

0 commit comments

Comments
 (0)