Skip to content

Commit d632ae0

Browse files
committed
Cache node deltas in resource quotas
1 parent 4177783 commit d632ae0

File tree

7 files changed

+220
-50
lines changed

7 files changed

+220
-50
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package resourcequotas
18+
19+
import (
20+
corev1 "k8s.io/api/core/v1"
21+
"k8s.io/autoscaler/cluster-autoscaler/cloudprovider"
22+
cacontext "k8s.io/autoscaler/cluster-autoscaler/context"
23+
"k8s.io/autoscaler/cluster-autoscaler/processors/customresources"
24+
)
25+
26+
type nodeCache struct {
27+
crp customresources.CustomResourcesProcessor
28+
deltas map[string]resourceList
29+
}
30+
31+
func newNodeCache(crp customresources.CustomResourcesProcessor) *nodeCache {
32+
return &nodeCache{
33+
crp: crp,
34+
deltas: make(map[string]resourceList),
35+
}
36+
}
37+
38+
func (nc *nodeCache) nodeResources(autoscalingCtx *cacontext.AutoscalingContext, node *corev1.Node, nodeGroup cloudprovider.NodeGroup) (resourceList, error) {
39+
if nodeGroup != nil {
40+
if delta, ok := nc.deltas[nodeGroup.Id()]; ok {
41+
return delta, nil
42+
}
43+
}
44+
delta, err := nodeResources(autoscalingCtx, nc.crp, node, nodeGroup)
45+
if err != nil {
46+
return nil, err
47+
}
48+
if nodeGroup != nil {
49+
nc.deltas[nodeGroup.Id()] = delta
50+
}
51+
return delta, nil
52+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package resourcequotas
18+
19+
import (
20+
"testing"
21+
22+
"github.com/google/go-cmp/cmp"
23+
"github.com/stretchr/testify/mock"
24+
apiv1 "k8s.io/api/core/v1"
25+
"k8s.io/autoscaler/cluster-autoscaler/cloudprovider"
26+
cptest "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/test"
27+
"k8s.io/autoscaler/cluster-autoscaler/context"
28+
"k8s.io/autoscaler/cluster-autoscaler/processors/customresources"
29+
drasnapshot "k8s.io/autoscaler/cluster-autoscaler/simulator/dynamicresources/snapshot"
30+
"k8s.io/autoscaler/cluster-autoscaler/utils/errors"
31+
"k8s.io/autoscaler/cluster-autoscaler/utils/test"
32+
)
33+
34+
type mockCustomResourcesProcessor struct {
35+
mock.Mock
36+
}
37+
38+
func (m *mockCustomResourcesProcessor) FilterOutNodesWithUnreadyResources(autoscalingCtx *context.AutoscalingContext, allNodes, readyNodes []*apiv1.Node, draSnapshot *drasnapshot.Snapshot) ([]*apiv1.Node, []*apiv1.Node) {
39+
return allNodes, readyNodes
40+
}
41+
42+
func (m *mockCustomResourcesProcessor) GetNodeResourceTargets(autoscalingCtx *context.AutoscalingContext, node *apiv1.Node, nodeGroup cloudprovider.NodeGroup) ([]customresources.CustomResourceTarget, errors.AutoscalerError) {
43+
args := m.Called(autoscalingCtx, node, nodeGroup)
44+
return args.Get(0).([]customresources.CustomResourceTarget), nil
45+
}
46+
47+
func (m *mockCustomResourcesProcessor) CleanUp() {
48+
return
49+
}
50+
51+
func TestNodeCacheNodeResources(t *testing.T) {
52+
node := test.BuildTestNode("n1", 1000, 2000)
53+
autoscalingCtx := &context.AutoscalingContext{}
54+
ng1 := cptest.NewTestNodeGroup("ng1", 1, 10, 1, true, false, "n1-template", nil, nil)
55+
ng2 := cptest.NewTestNodeGroup("ng2", 1, 10, 1, true, false, "n2-template", nil, nil)
56+
resourceTargets := []customresources.CustomResourceTarget{
57+
{ResourceType: "gpu", ResourceCount: 1},
58+
}
59+
wantResources := resourceList{"cpu": 1, "memory": 2000, "nodes": 1, "gpu": 1}
60+
61+
type nodeResourcesCall struct {
62+
node *apiv1.Node
63+
nodeGroup cloudprovider.NodeGroup
64+
}
65+
66+
testCases := []struct {
67+
name string
68+
calls []nodeResourcesCall
69+
setupCRPExpectations func(*mock.Mock)
70+
}{
71+
{
72+
name: "cache hit",
73+
calls: []nodeResourcesCall{
74+
{node: node, nodeGroup: ng1},
75+
{node: node, nodeGroup: ng1},
76+
},
77+
setupCRPExpectations: func(m *mock.Mock) {
78+
m.On("GetNodeResourceTargets", autoscalingCtx, node, ng1).Return(resourceTargets, nil).Once()
79+
},
80+
},
81+
{
82+
name: "cache miss on different node group",
83+
calls: []nodeResourcesCall{
84+
{node: node, nodeGroup: ng1},
85+
{node: node, nodeGroup: ng2},
86+
},
87+
setupCRPExpectations: func(m *mock.Mock) {
88+
m.On("GetNodeResourceTargets", autoscalingCtx, node, ng1).Return(resourceTargets, nil).Once().
89+
On("GetNodeResourceTargets", autoscalingCtx, node, ng2).Return(resourceTargets, nil).Once()
90+
},
91+
},
92+
{
93+
name: "no node group bypasses cache",
94+
calls: []nodeResourcesCall{
95+
{node: node, nodeGroup: nil},
96+
{node: node, nodeGroup: nil},
97+
},
98+
setupCRPExpectations: func(m *mock.Mock) {
99+
m.On("GetNodeResourceTargets", autoscalingCtx, node, nil).Return(resourceTargets, nil).Twice()
100+
},
101+
},
102+
}
103+
for _, tc := range testCases {
104+
t.Run(tc.name, func(t *testing.T) {
105+
mockCRP := &mockCustomResourcesProcessor{}
106+
tc.setupCRPExpectations(&mockCRP.Mock)
107+
nc := newNodeCache(mockCRP)
108+
for _, call := range tc.calls {
109+
resources, err := nc.nodeResources(autoscalingCtx, call.node, call.nodeGroup)
110+
if err != nil {
111+
t.Fatalf("nodeResources unexpected error: %v", err)
112+
}
113+
if diff := cmp.Diff(wantResources, resources); diff != "" {
114+
t.Errorf("nodeResources mismatch (-want, +got):\n%s", diff)
115+
}
116+
}
117+
})
118+
}
119+
}

cluster-autoscaler/resourcequotas/factory.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import (
2424

2525
// TrackerFactory builds quota trackers.
2626
type TrackerFactory struct {
27-
crp customresources.CustomResourcesProcessor
28-
quotasProvider Provider
29-
usageCalculator *usageCalculator
27+
crp customresources.CustomResourcesProcessor
28+
quotasProvider Provider
29+
nodeFilter NodeFilter
3030
}
3131

3232
// TrackerOptions stores configuration for quota tracking.
@@ -38,11 +38,10 @@ type TrackerOptions struct {
3838

3939
// NewTrackerFactory creates a new TrackerFactory.
4040
func NewTrackerFactory(opts TrackerOptions) *TrackerFactory {
41-
uc := newUsageCalculator(opts.CustomResourcesProcessor, opts.NodeFilter)
4241
return &TrackerFactory{
43-
crp: opts.CustomResourcesProcessor,
44-
quotasProvider: opts.QuotaProvider,
45-
usageCalculator: uc,
42+
crp: opts.CustomResourcesProcessor,
43+
quotasProvider: opts.QuotaProvider,
44+
nodeFilter: opts.NodeFilter,
4645
}
4746
}
4847

@@ -56,7 +55,9 @@ func (f *TrackerFactory) NewQuotasTracker(autoscalingCtx *context.AutoscalingCon
5655
if err != nil {
5756
return nil, err
5857
}
59-
usages, err := f.usageCalculator.calculateUsages(autoscalingCtx, nodes, quotas)
58+
nc := newNodeCache(f.crp)
59+
uc := newUsageCalculator(f.nodeFilter, nc)
60+
usages, err := uc.calculateUsages(autoscalingCtx, nodes, quotas)
6061
if err != nil {
6162
return nil, err
6263
}
@@ -73,6 +74,6 @@ func (f *TrackerFactory) NewQuotasTracker(autoscalingCtx *context.AutoscalingCon
7374
limitsLeft: limitsLeft,
7475
})
7576
}
76-
tracker := newTracker(f.crp, quotaStatuses)
77+
tracker := newTracker(quotaStatuses, nc)
7778
return tracker, nil
7879
}

cluster-autoscaler/resourcequotas/tracker.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ type resourceList map[string]int64
4545

4646
// Tracker tracks resource quotas.
4747
type Tracker struct {
48-
crp customresources.CustomResourcesProcessor
4948
quotaStatuses []*quotaStatus
49+
nodeCache *nodeCache
5050
}
5151

5252
type quotaStatus struct {
@@ -55,10 +55,10 @@ type quotaStatus struct {
5555
}
5656

5757
// newTracker creates a new Tracker.
58-
func newTracker(crp customresources.CustomResourcesProcessor, quotaStatuses []*quotaStatus) *Tracker {
58+
func newTracker(quotaStatuses []*quotaStatus, nodeCache *nodeCache) *Tracker {
5959
return &Tracker{
60-
crp: crp,
6160
quotaStatuses: quotaStatuses,
61+
nodeCache: nodeCache,
6262
}
6363
}
6464

@@ -67,7 +67,7 @@ func newTracker(crp customresources.CustomResourcesProcessor, quotaStatuses []*q
6767
func (t *Tracker) ApplyDelta(
6868
autoscalingCtx *context.AutoscalingContext, nodeGroup cloudprovider.NodeGroup, node *corev1.Node, nodeDelta int,
6969
) (*CheckDeltaResult, error) {
70-
delta, err := nodeResources(autoscalingCtx, t.crp, node, nodeGroup)
70+
delta, err := t.nodeCache.nodeResources(autoscalingCtx, node, nodeGroup)
7171
if err != nil {
7272
return nil, err
7373
}
@@ -100,8 +100,7 @@ func (t *Tracker) ApplyDelta(
100100
func (t *Tracker) CheckDelta(
101101
autoscalingCtx *context.AutoscalingContext, nodeGroup cloudprovider.NodeGroup, node *corev1.Node, nodeDelta int,
102102
) (*CheckDeltaResult, error) {
103-
// TODO: cache deltas
104-
delta, err := nodeResources(autoscalingCtx, t.crp, node, nodeGroup)
103+
delta, err := t.nodeCache.nodeResources(autoscalingCtx, node, nodeGroup)
105104
if err != nil {
106105
return nil, err
107106
}

0 commit comments

Comments
 (0)