Skip to content

Commit d77c0e9

Browse files
authored
Merge pull request #54 from planetscale/joem/fix-too-frequent-updates
fix: too frequent cloud api updates
2 parents a922251 + 0e4db4c commit d77c0e9

File tree

3 files changed

+78
-30
lines changed

3 files changed

+78
-30
lines changed

controller.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,16 @@ func (r *NodeLabelController) SetupWithManager(mgr ctrl.Manager) error {
7676
return true
7777
}
7878

79-
// Also process if node has monitored labels (catches resync events).
80-
// During resync, old == new, so shouldProcessNodeUpdate returns false,
81-
// but we still want to reconcile to catch any missed events.
82-
if shouldProcessNodeCreate(newNode, r.Labels, r.Annotations) {
83-
r.Logger.V(1).Info("Update event: resync", "node", newNode.Name)
84-
return true
79+
// During periodic resync, controller-runtime emits Update events where
80+
// oldObj and newObj are identical (same ResourceVersion). Allow these
81+
// through for nodes with monitored labels to catch any missed events.
82+
// But filter out real updates (different ResourceVersion) where only
83+
// non-label fields changed (e.g., heartbeat, status updates).
84+
if oldNode.ResourceVersion == newNode.ResourceVersion {
85+
if shouldProcessNodeCreate(newNode, r.Labels, r.Annotations) {
86+
r.Logger.V(1).Info("Update event: periodic resync", "node", newNode.Name)
87+
return true
88+
}
8589
}
8690

8791
return false

controller_test.go

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ func (m *mockGCEClient) SetLabels(ctx context.Context, project, zone, instance s
5757
}
5858

5959
type mockNode struct {
60-
Name string
61-
Labels map[string]string
62-
Annotations map[string]string
63-
ProviderID string
60+
Name string
61+
Labels map[string]string
62+
Annotations map[string]string
63+
ProviderID string
64+
ResourceVersion string
6465
}
6566

6667
func TestReconcileAWS(t *testing.T) {
@@ -652,16 +653,33 @@ func TestSanitizeKeysForGCP(t *testing.T) {
652653
func createNode(config mockNode) *corev1.Node {
653654
return &corev1.Node{
654655
ObjectMeta: metav1.ObjectMeta{
655-
Name: config.Name,
656-
Labels: config.Labels,
657-
Annotations: config.Annotations,
656+
Name: config.Name,
657+
Labels: config.Labels,
658+
Annotations: config.Annotations,
659+
ResourceVersion: config.ResourceVersion,
658660
},
659661
Spec: corev1.NodeSpec{
660662
ProviderID: config.ProviderID,
661663
},
662664
}
663665
}
664666

667+
// simulateUpdatePredicate matches the actual predicate logic in SetupWithManager.
668+
// Returns true if the update event should trigger reconciliation.
669+
func simulateUpdatePredicate(oldNode, newNode *corev1.Node, monitoredLabels, monitoredAnnotations []string) bool {
670+
// Process if any monitored label/annotation changed
671+
if shouldProcessNodeUpdate(oldNode, newNode, monitoredLabels, monitoredAnnotations) {
672+
return true
673+
}
674+
675+
// During periodic resync (same ResourceVersion), allow if node has monitored labels
676+
if oldNode.ResourceVersion == newNode.ResourceVersion {
677+
return shouldProcessNodeCreate(newNode, monitoredLabels, monitoredAnnotations)
678+
}
679+
680+
return false
681+
}
682+
665683
// TestPredicateToReconcileFlow tests the full flow from event through predicate to reconcile.
666684
// This simulates what controller-runtime does: events are filtered by predicates, and only
667685
// if the predicate allows, reconcile is called.
@@ -723,20 +741,41 @@ func TestPredicateToReconcileFlow(t *testing.T) {
723741
expectTagsCreated: []string{"env"}, // only env should be synced
724742
},
725743
{
726-
name: "node update that does not change monitored labels triggers resync",
744+
name: "periodic resync with same ResourceVersion triggers reconcile",
727745
monitoredLabels: []string{"env"},
728746
initialNode: mockNode{
729-
Name: "node1",
730-
Labels: map[string]string{"env": "prod"},
731-
ProviderID: "aws:///us-east-1a/i-1234567890abcdef0",
747+
Name: "node1",
748+
Labels: map[string]string{"env": "prod"},
749+
ProviderID: "aws:///us-east-1a/i-1234567890abcdef0",
750+
ResourceVersion: "12345",
732751
},
733752
updatedNode: &mockNode{
734-
Name: "node1",
735-
Labels: map[string]string{"env": "prod", "unrelated": "change"},
736-
ProviderID: "aws:///us-east-1a/i-1234567890abcdef0",
753+
Name: "node1",
754+
Labels: map[string]string{"env": "prod"}, // same labels
755+
ProviderID: "aws:///us-east-1a/i-1234567890abcdef0",
756+
ResourceVersion: "12345", // same ResourceVersion = true resync
737757
},
738758
expectReconcileOnCreate: true,
739-
expectReconcileOnUpdate: true, // resync: node has monitored labels
759+
expectReconcileOnUpdate: true, // true resync: same ResourceVersion
760+
expectTagsCreated: []string{"env"},
761+
},
762+
{
763+
name: "heartbeat update with different ResourceVersion does not trigger reconcile",
764+
monitoredLabels: []string{"env"},
765+
initialNode: mockNode{
766+
Name: "node1",
767+
Labels: map[string]string{"env": "prod"},
768+
ProviderID: "aws:///us-east-1a/i-1234567890abcdef0",
769+
ResourceVersion: "12345",
770+
},
771+
updatedNode: &mockNode{
772+
Name: "node1",
773+
Labels: map[string]string{"env": "prod"}, // same labels
774+
ProviderID: "aws:///us-east-1a/i-1234567890abcdef0",
775+
ResourceVersion: "12346", // different ResourceVersion = real update, not resync
776+
},
777+
expectReconcileOnCreate: true,
778+
expectReconcileOnUpdate: false, // NOT a resync, just heartbeat - skip
740779
expectTagsCreated: []string{"env"},
741780
},
742781
{
@@ -812,20 +851,25 @@ func TestPredicateToReconcileFlow(t *testing.T) {
812851
mock.createdTags = nil
813852
mock.currentTags = []types.TagDescription{} // EC2 has no tags yet (simulating missed create)
814853

815-
// Update the node in the fake client
854+
// Create the updated node object directly (not through fake client)
855+
// to test predicate logic with explicit ResourceVersions
816856
updatedNodeObj := createNode(*tt.updatedNode)
817-
err := k8s.Update(context.Background(), updatedNodeObj)
818-
require.NoError(t, err)
819857

820-
// Match the actual predicate logic: allow if labels changed OR if node has monitored labels (resync)
821-
updateAllowed := shouldProcessNodeUpdate(initialNodeObj, updatedNodeObj, tt.monitoredLabels, []string{}) ||
822-
shouldProcessNodeCreate(updatedNodeObj, tt.monitoredLabels, []string{})
858+
// Use the helper that matches actual predicate logic
859+
updateAllowed := simulateUpdatePredicate(initialNodeObj, updatedNodeObj, tt.monitoredLabels, []string{})
823860

824861
assert.Equal(t, tt.expectReconcileOnUpdate, updateAllowed,
825862
"Update predicate returned unexpected result")
826863

827864
if updateAllowed {
828-
_, err := controller.Reconcile(context.Background(), ctrl.Request{
865+
// For reconcile test, update the node in the fake client
866+
// (without explicit ResourceVersion to avoid conflicts)
867+
nodeForClient := createNode(*tt.updatedNode)
868+
nodeForClient.ResourceVersion = "" // let fake client manage this
869+
err := k8s.Update(context.Background(), nodeForClient)
870+
require.NoError(t, err)
871+
872+
_, err = controller.Reconcile(context.Background(), ctrl.Request{
829873
NamespacedName: client.ObjectKey{Name: tt.updatedNode.Name},
830874
})
831875
require.NoError(t, err)

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
module github.com/planetscale/k8s-node-tagger
22

3-
go 1.24.0
3+
go 1.25.0
44

5-
toolchain go1.24.5
5+
toolchain go1.25.5
66

77
require (
88
github.com/aws/aws-sdk-go-v2 v1.36.5

0 commit comments

Comments
 (0)