Skip to content

Commit 6bf0e58

Browse files
authored
fix: bound policy ancestors on the fly to avoid O(N^2) translation latency (#9558)
* fix: bound policy ancestors on the fly to avoid O(N^2) translation latency When many resources reference the same target, a policy's status.Ancestors list could grow without bound during translation. SetConditionForPolicyAncestor scans the existing ancestors on every call, so building a status with N ancestors was O(N^2); for a BackendTLSPolicy shared by e.g. 10k EnvoyExtensionPolicies this pushed translation time to ~1h. Cap status.Ancestors at maxPolicyAncestors+1 (17) as entries are added: SetConditionForPolicyAncestor evicts the lowest-priority ancestor once the list exceeds the soft cap, keeping every insert O(1) and the total O(N). The single slot above the CRD limit lets the post-processing TruncatePolicyAncestors still detect overflow, cut to 16, and stamp the Aggregated condition. Note: because eviction happens as ancestors are added, before an ancestor's rank is necessarily final, a policy with more than 16 ancestors that later marks a truncated ancestor Overridden shows only the Overridden condition on it - the redundant Accepted condition is dropped. This affects only truncated statuses. Fixes #9539 Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> * update release note Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> --------- Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com>
1 parent eda050e commit 6bf0e58

6 files changed

Lines changed: 123 additions & 41 deletions

File tree

internal/gatewayapi/status/policy.go

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ const (
2727
PolicyReasonMultipleWarnings gwapiv1.PolicyConditionReason = "Warnings"
2828
)
2929

30+
const (
31+
// maxPolicyAncestors is the Gateway API CRD limit on the number of entries
32+
// allowed in status.ancestors.
33+
maxPolicyAncestors = 16
34+
35+
// policyAncestorsSoftCap bounds policyStatus.Ancestors as entries are added, so
36+
// SetConditionForPolicyAncestor stays O(1) instead of O(N) at scale (see #9539). It is
37+
// intentionally one slot above the CRD limit: keeping a single extra ancestor lets the
38+
// post-processing TruncatePolicyAncestors still observe len > maxPolicyAncestors, sort, cut to
39+
// maxPolicyAncestors, and stamp the Aggregated condition. Since the best maxPolicyAncestors
40+
// ancestors are a subset of the best policyAncestorsSoftCap, the final survivors match the
41+
// unbounded behavior for policies whose conditions do not change rank across passes.
42+
policyAncestorsSoftCap = maxPolicyAncestors + 1
43+
)
44+
3045
type PolicyResolveError struct {
3146
Reason gwapiv1.PolicyConditionReason
3247
Message string
@@ -166,6 +181,32 @@ func SetConditionForPolicyAncestor(policyStatus *gwapiv1.PolicyStatus, ancestorR
166181
ControllerName: gwapiv1a2.GatewayController(controllerName),
167182
Conditions: []metav1.Condition{cond},
168183
})
184+
185+
// Keep the ancestor list bounded as entries are added so this stays O(1) even when a policy is
186+
// referenced by a very large number of ancestors (see #9539).
187+
boundPolicyAncestors(policyStatus)
188+
}
189+
190+
// boundPolicyAncestors keeps policyStatus.Ancestors within policyAncestorsSoftCap entries by
191+
// evicting the single lowest-priority ancestor, using the same ordering as TruncatePolicyAncestors.
192+
// It is called as each ancestor is added so status building stays O(1) per insert. Because eviction
193+
// happens before an ancestor's rank is necessarily final, a truncated ancestor that later becomes
194+
// higher-priority (e.g. Overridden) is re-added carrying only that later condition; the redundant
195+
// Accepted condition is lost for policies with more than maxPolicyAncestors ancestors. The one slot
196+
// above the CRD limit lets the post-processing TruncatePolicyAncestors still stamp Aggregated.
197+
func boundPolicyAncestors(policyStatus *gwapiv1.PolicyStatus) {
198+
if len(policyStatus.Ancestors) <= policyAncestorsSoftCap {
199+
return
200+
}
201+
202+
// Find the lowest-priority ancestor: the one no other ancestor ranks below.
203+
worst := 0
204+
for i := 1; i < len(policyStatus.Ancestors); i++ {
205+
if lessPolicyAncestor(&policyStatus.Ancestors[worst], &policyStatus.Ancestors[i]) {
206+
worst = i
207+
}
208+
}
209+
policyStatus.Ancestors = append(policyStatus.Ancestors[:worst], policyStatus.Ancestors[worst+1:]...)
169210
}
170211

171212
// buildDeprecationWarningMessage builds a warning message from a map of deprecated fields to their alternatives.
@@ -243,42 +284,22 @@ func ancestorRefsEqual(a, b *gwapiv1.ParentReference) bool {
243284
// The first 15 ancestors are shown as is.
244285
// The last 16th ancestor is shown as is and add Aggregated condition.
245286
func TruncatePolicyAncestors(policyStatus *gwapiv1.PolicyStatus, controllerName string, generation int64) {
246-
if len(policyStatus.Ancestors) <= 16 {
287+
if len(policyStatus.Ancestors) <= maxPolicyAncestors {
247288
return
248289
}
249290

250-
// we need to truncate policy ancestor status due to the item limit (max 16).
251-
// so we are choosing to preserve the 16 most important ancestors.
291+
// we need to truncate policy ancestor status due to the item limit (max maxPolicyAncestors).
292+
// so we are choosing to preserve the maxPolicyAncestors most important ancestors.
252293
// negative polarity (Conflicted, Overridden...) should be clearly indicated to the user.
253294
sort.Slice(policyStatus.Ancestors, func(i, j int) bool {
254-
a, b := policyStatus.Ancestors[i], policyStatus.Ancestors[j]
255-
aRank := sortRankForPolicyAncestor(&a)
256-
bRank := sortRankForPolicyAncestor(&b)
257-
258-
if aRank != bRank {
259-
return aRank < bRank
260-
}
261-
// First compare by namespace, then by name
262-
aNamespace := ""
263-
if a.AncestorRef.Namespace != nil {
264-
aNamespace = string(*a.AncestorRef.Namespace)
265-
}
266-
bNamespace := ""
267-
if b.AncestorRef.Namespace != nil {
268-
bNamespace = string(*b.AncestorRef.Namespace)
269-
}
270-
271-
if aNamespace != bNamespace {
272-
return aNamespace < bNamespace
273-
}
274-
return string(a.AncestorRef.Name) < string(b.AncestorRef.Name)
295+
return lessPolicyAncestor(&policyStatus.Ancestors[i], &policyStatus.Ancestors[j])
275296
})
276297

277298
aggregatedMessage := "Ancestors have been truncated because the number of policy ancestors exceeds 16."
278299

279-
policyStatus.Ancestors = policyStatus.Ancestors[:16]
300+
policyStatus.Ancestors = policyStatus.Ancestors[:maxPolicyAncestors]
280301
SetConditionForPolicyAncestor(policyStatus,
281-
&policyStatus.Ancestors[15].AncestorRef,
302+
&policyStatus.Ancestors[maxPolicyAncestors-1].AncestorRef,
282303
controllerName,
283304
egv1a1.PolicyConditionAggregated,
284305
metav1.ConditionTrue,
@@ -288,6 +309,31 @@ func TruncatePolicyAncestors(policyStatus *gwapiv1.PolicyStatus, controllerName
288309
)
289310
}
290311

312+
// lessPolicyAncestor reports whether ancestor a should be ordered before ancestor b when deciding
313+
// which ancestors to preserve under the CRD limit: by sort rank (negative polarity first), then
314+
// namespace, then name.
315+
func lessPolicyAncestor(a, b *gwapiv1.PolicyAncestorStatus) bool {
316+
aRank := sortRankForPolicyAncestor(a)
317+
bRank := sortRankForPolicyAncestor(b)
318+
if aRank != bRank {
319+
return aRank < bRank
320+
}
321+
// First compare by namespace, then by name
322+
aNamespace := ""
323+
if a.AncestorRef.Namespace != nil {
324+
aNamespace = string(*a.AncestorRef.Namespace)
325+
}
326+
bNamespace := ""
327+
if b.AncestorRef.Namespace != nil {
328+
bNamespace = string(*b.AncestorRef.Namespace)
329+
}
330+
331+
if aNamespace != bNamespace {
332+
return aNamespace < bNamespace
333+
}
334+
return string(a.AncestorRef.Name) < string(b.AncestorRef.Name)
335+
}
336+
291337
// sortRankForPolicyAncestor returns an integer sort key.
292338
// ranking rules (smaller value -> higher priority):
293339
//

internal/gatewayapi/status/policy_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package status
77

88
import (
9+
"fmt"
910
"strings"
1011
"testing"
1112

@@ -16,6 +17,55 @@ import (
1617
egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
1718
)
1819

20+
// TestSetConditionForPolicyAncestorBounded verifies the ancestor list is capped on the fly as
21+
// entries are added (see #9539) and that the highest-priority (not-accepted) ancestors are kept.
22+
func TestSetConditionForPolicyAncestorBounded(t *testing.T) {
23+
const controller = "example.com/controller"
24+
policyStatus := &gwapiv1.PolicyStatus{}
25+
26+
// A few not-accepted (rank 0) ancestors, then many accepted (rank 2) ones.
27+
badNames := []string{"bad-0", "bad-1", "bad-2"}
28+
for _, name := range badNames {
29+
ref := &gwapiv1.ParentReference{Name: gwapiv1.ObjectName(name)}
30+
SetConditionForPolicyAncestor(policyStatus, ref, controller,
31+
gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, gwapiv1.PolicyReasonConflicted, "conflicted", 1)
32+
}
33+
for i := range 100 {
34+
ref := &gwapiv1.ParentReference{Name: gwapiv1.ObjectName(fmt.Sprintf("gw-%03d", i))}
35+
SetConditionForPolicyAncestor(policyStatus, ref, controller,
36+
gwapiv1.PolicyConditionAccepted, metav1.ConditionTrue, gwapiv1.PolicyReasonAccepted, "accepted", 1)
37+
}
38+
39+
// Bounded on the fly at the soft cap, never growing to 103.
40+
assert.Len(t, policyStatus.Ancestors, policyAncestorsSoftCap)
41+
// The not-accepted (rank 0) ancestors are preserved; only rank-2 entries are evicted.
42+
for _, name := range badNames {
43+
found := false
44+
for _, a := range policyStatus.Ancestors {
45+
if string(a.AncestorRef.Name) == name {
46+
found = true
47+
}
48+
}
49+
assert.True(t, found, "not-accepted ancestor %q must be preserved", name)
50+
}
51+
52+
// Final truncation still cuts to the CRD limit.
53+
TruncatePolicyAncestors(policyStatus, controller, 1)
54+
assert.Len(t, policyStatus.Ancestors, maxPolicyAncestors)
55+
}
56+
57+
func BenchmarkSetConditionForPolicyAncestorsAtScale(b *testing.B) {
58+
const controller = "example.com/controller"
59+
for b.Loop() {
60+
policyStatus := &gwapiv1.PolicyStatus{}
61+
for i := range 10000 {
62+
ref := &gwapiv1.ParentReference{Name: gwapiv1.ObjectName(fmt.Sprintf("gw-%05d", i))}
63+
SetConditionForPolicyAncestor(policyStatus, ref, controller,
64+
gwapiv1.PolicyConditionAccepted, metav1.ConditionTrue, gwapiv1.PolicyReasonAccepted, "accepted", 1)
65+
}
66+
}
67+
}
68+
1969
func TestSetConditionForPolicyAncestorsTruncatesMessages(t *testing.T) {
2070
longMsg := strings.Repeat("x", conditionMessageMaxLength+5)
2171
policyStatus := &gwapiv1.PolicyStatus{}

internal/gatewayapi/testdata/backendtrafficpolicy-status-conditions-truncated.out.yaml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,6 @@ backendTrafficPolicies:
2020
name: gateway-9
2121
namespace: envoy-gateway
2222
conditions:
23-
- lastTransitionTime: null
24-
message: Policy has been accepted.
25-
reason: Accepted
26-
status: "True"
27-
type: Accepted
2823
- lastTransitionTime: null
2924
message: 'This policy is being overridden by other backendTrafficPolicies
3025
for these routes: [envoy-gateway/httproute-1 envoy-gateway/httproute-2]'

internal/gatewayapi/testdata/envoyextensionpolicy-status-conditions-truncated.out.yaml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,6 @@ envoyExtensionPolicies:
2020
name: gateway-9
2121
namespace: envoy-gateway
2222
conditions:
23-
- lastTransitionTime: null
24-
message: Policy has been accepted.
25-
reason: Accepted
26-
status: "True"
27-
type: Accepted
2823
- lastTransitionTime: null
2924
message: 'This policy is being overridden by other envoyExtensionPolicies
3025
for these routes: [envoy-gateway/httproute-1 envoy-gateway/httproute-2]'

internal/gatewayapi/testdata/securitypolicy-status-conditions-truncated.out.yaml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,11 +1729,6 @@ securityPolicies:
17291729
name: gateway-9
17301730
namespace: envoy-gateway
17311731
conditions:
1732-
- lastTransitionTime: null
1733-
message: Policy has been accepted.
1734-
reason: Accepted
1735-
status: "True"
1736-
type: Accepted
17371732
- lastTransitionTime: null
17381733
message: 'This policy is being overridden by other securityPolicies for these
17391734
routes: [envoy-gateway/httproute-1 envoy-gateway/httproute-2]'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed translation latency growing quadratically when many policies share the same target by capping each policy's `status.ancestors` at the CRD limit as ancestors are added, instead of only truncating during post-processing.

0 commit comments

Comments
 (0)