Skip to content

Commit a3acaba

Browse files
authored
performance: opt-in debounce for the provider reconcile (#9803)
* add opt-in debounce for the provider reconcile Every watch in the gatewayapi controller enqueues the same GatewayClass request, so the workqueue already collapses events that arrive while a reconcile is in flight: the request sits in the dirty set and is requeued exactly once when Done is called. What the workqueue does not do is wait. When a reconcile is fast relative to the event rate the queue drains between events, and each event then costs a full rebuild of the resource tree even though only the resulting state matters. Wrap the controller's workqueue so that Add is held until no new request has arrived for debounce.after, bounded by debounce.max so that sustained churn cannot defer a reconcile indefinitely. AddAfter and AddRateLimited reach the embedded queue directly, so error backoff is never delayed. Reuses the same top-level debounce field in the EnvoyGateway config, and is disabled by default. Note that status is computed during reconcile, so enabling this also delays status updates by up to debounce.max. Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> Signed-off-by: Huabing (Robin) Zhao <huabing@tetrate.io> * test: run conformance and e2e with debounce enabled Add a debounce profile that turns on reconcile debouncing at the shipped defaults, and a conformance and an e2e matrix entry that run against it, so holding a burst of resource changes before reconciling is covered by the existing suites rather than only by unit tests. Conformance configures Envoy Gateway from the helm values profile, while e2e additionally applies the envoy-gateway-config ConfigMap, so both files are needed for the profile to take effect in both suites. Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> Signed-off-by: Huabing (Robin) Zhao <huabing@tetrate.io> * address comments Signed-off-by: Huabing (Robin) Zhao <huabing@tetrate.io> --------- Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> Signed-off-by: Huabing (Robin) Zhao <huabing@tetrate.io>
1 parent 25775c2 commit a3acaba

15 files changed

Lines changed: 816 additions & 1 deletion

File tree

.github/workflows/build_and_test.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ jobs:
202202
ipFamily: ipv4
203203
profile: merge-backends
204204
gwapiChannel: experimental
205+
- version: v1.36.1
206+
ipFamily: ipv4
207+
profile: debounce
208+
gwapiChannel: experimental
205209
steps:
206210
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
207211
- uses: ./tools/github-actions/setup-deps
@@ -273,6 +277,9 @@ jobs:
273277
- version: v1.36.1
274278
ipFamily: ipv4
275279
profile: watch-namespaces
280+
- version: v1.36.1
281+
ipFamily: ipv4
282+
profile: debounce
276283

277284
steps:
278285
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

api/v1alpha1/envoygateway_helpers.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
package v1alpha1
77

88
import (
9+
"fmt"
910
"net"
1011
"strconv"
12+
"time"
1113

1214
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1315
"k8s.io/utils/ptr"
@@ -239,6 +241,52 @@ func DefaultXDSServer() *XDSServer {
239241
return &XDSServer{}
240242
}
241243

244+
const (
245+
// DefaultDebounceAfter is the default quiet period before a pending batch of
246+
// resource changes is flushed.
247+
DefaultDebounceAfter = 100 * time.Millisecond
248+
249+
// DefaultDebounceMax is the default upper bound on how long a resource change
250+
// may be held before a flush is forced.
251+
DefaultDebounceMax = 10 * time.Second
252+
)
253+
254+
// Enabled reports whether debouncing of resource changes is turned on. Debouncing
255+
// is opt in, so defining the config is what turns it on.
256+
func (d *Debounce) Enabled() bool {
257+
return d != nil
258+
}
259+
260+
// ResolveDurations returns the quiet period and the maximum hold time, substituting
261+
// the defaults for any field left unset. It is the single place these fields are
262+
// parsed, so every consumer agrees on what a given configuration means.
263+
func (d *Debounce) ResolveDurations() (after, maxHold time.Duration, err error) {
264+
after, maxHold = DefaultDebounceAfter, DefaultDebounceMax
265+
if d == nil {
266+
return after, maxHold, nil
267+
}
268+
269+
if d.After != nil {
270+
if after, err = time.ParseDuration(string(*d.After)); err != nil {
271+
return 0, 0, fmt.Errorf("invalid debounce.after: %w", err)
272+
}
273+
if after <= 0 {
274+
return 0, 0, fmt.Errorf("debounce.after must be greater than zero")
275+
}
276+
}
277+
278+
if d.Max != nil {
279+
if maxHold, err = time.ParseDuration(string(*d.Max)); err != nil {
280+
return 0, 0, fmt.Errorf("invalid debounce.max: %w", err)
281+
}
282+
if maxHold <= 0 {
283+
return 0, 0, fmt.Errorf("debounce.max must be greater than zero")
284+
}
285+
}
286+
287+
return after, maxHold, nil
288+
}
289+
242290
// DefaultEnvoyGatewayLogging returns a new EnvoyGatewayLogging with default configuration parameters.
243291
func DefaultEnvoyGatewayLogging() *EnvoyGatewayLogging {
244292
return &EnvoyGatewayLogging{

api/v1alpha1/envoygateway_helpers_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010

1111
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
1213
)
1314

1415
func TestIsRunningOnKubernetes(t *testing.T) {
@@ -430,3 +431,20 @@ func TestGetKubernetesInfrastructureConfiguration(t *testing.T) {
430431
})
431432
}
432433
}
434+
435+
func TestDebounceDefaultsToDisabled(t *testing.T) {
436+
eg := &EnvoyGateway{}
437+
eg.SetEnvoyGatewayDefaults()
438+
439+
// Debouncing is opt in, so defaulting must not define the config, and defining
440+
// it is what turns debouncing on.
441+
require.Nil(t, eg.Debounce)
442+
require.False(t, eg.Debounce.Enabled())
443+
require.True(t, (&Debounce{}).Enabled())
444+
445+
// A config that sets no durations falls back to the advertised defaults.
446+
after, maxHold, err := (&Debounce{}).ResolveDurations()
447+
require.NoError(t, err)
448+
require.Equal(t, DefaultDebounceAfter, after)
449+
require.Equal(t, DefaultDebounceMax, maxHold)
450+
}

api/v1alpha1/envoygateway_types.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ type EnvoyGatewaySpec struct {
8282
// +optional
8383
XDSServer *XDSServer `json:"xdsServer,omitempty"`
8484

85+
// Debounce defines how Envoy Gateway coalesces bursts of resource changes
86+
// before reconciling them into new configuration for Envoy Proxy.
87+
// If unspecified, debouncing is disabled and every change is reconciled on
88+
// its own. Applies to the Kubernetes provider only.
89+
//
90+
// +optional
91+
Debounce *Debounce `json:"debounce,omitempty"`
92+
8593
// RateLimit defines the configuration associated with the Rate Limit service
8694
// deployed by Envoy Gateway required to implement the Global Rate limiting
8795
// functionality. The specific rate limit service used here is the reference
@@ -246,6 +254,46 @@ type XDSServer struct {
246254
MaxReceiveMessageSize *resource.Quantity `json:"maxReceiveMessageSize,omitempty"`
247255
}
248256

257+
// Debounce defines how Envoy Gateway coalesces bursts of resource changes before
258+
// reconciling them into new configuration for Envoy Proxy.
259+
//
260+
// Without debouncing, each resource change costs a full reconcile, so a burst of
261+
// changes rebuilds the resource tree, retranslates and pushes once per change even
262+
// though only the resulting state matters. That spends control plane CPU on work that
263+
// is immediately superseded, and makes the proxies apply configuration that will be
264+
// replaced moments later.
265+
//
266+
// Debouncing merges changes that arrive close together into a single reconcile, so the
267+
// cost of a burst approaches that of a single change. The tradeoff is that propagation
268+
// of a change, and of the status derived from it, may be delayed by up to Max.
269+
//
270+
// Debouncing is opt in: it is on whenever this field is set, and off when it is
271+
// left unset.
272+
//
273+
// This applies to the Kubernetes provider only. It has no effect when the resource
274+
// provider is File, whose reconcile loop is driven directly by file change events.
275+
type Debounce struct {
276+
// After is the quiet period. A pending batch of changes is flushed once no new
277+
// change has arrived for this duration, so isolated changes still propagate
278+
// promptly.
279+
//
280+
// If unspecified, defaults to 100ms.
281+
//
282+
// +optional
283+
// +kubebuilder:default="100ms"
284+
After *gwapiv1.Duration `json:"after,omitempty"`
285+
286+
// Max bounds how long a change may be held before a flush is forced. Under
287+
// sustained churn the quiet period never elapses, so this caps how far behind
288+
// the proxies' configuration can fall.
289+
//
290+
// Must be greater than or equal to After. If unspecified, defaults to 10s.
291+
//
292+
// +optional
293+
// +kubebuilder:default="10s"
294+
Max *gwapiv1.Duration `json:"max,omitempty"`
295+
}
296+
249297
// LeaderElection defines the desired leader election settings.
250298
type LeaderElection struct {
251299
// LeaseDuration defines the time non-leader contenders will wait before attempting to claim leadership.

api/v1alpha1/validation/envoygateway_validate.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ func ValidateEnvoyGateway(eg *egv1a1.EnvoyGateway) error {
6868
return err
6969
}
7070

71+
if err := validateEnvoyGatewayDebounce(eg.Debounce); err != nil {
72+
return err
73+
}
74+
7175
if eg.ExtensionAPIs != nil && eg.ExtensionAPIs.DisableLua != nil && *eg.ExtensionAPIs.DisableLua == eg.ExtensionAPIs.EnableLua {
7276
return fmt.Errorf("disableLua and enableLua must not have the same value")
7377
}
@@ -355,6 +359,23 @@ func validateEnvoyGatewayXDSServer(xdsServer *egv1a1.XDSServer) error {
355359
return nil
356360
}
357361

362+
func validateEnvoyGatewayDebounce(debounce *egv1a1.Debounce) error {
363+
if debounce == nil {
364+
return nil
365+
}
366+
367+
after, maxHold, err := debounce.ResolveDurations()
368+
if err != nil {
369+
return err
370+
}
371+
372+
if maxHold < after {
373+
return fmt.Errorf("debounce.max (%s) must be greater than or equal to debounce.after (%s)", maxHold, after)
374+
}
375+
376+
return nil
377+
}
378+
358379
func validateEnvoyGatewayOpenTelemetrySink(sink *egv1a1.EnvoyGatewayOpenTelemetrySink) error {
359380
if sink.Protocol != egv1a1.GRPCProtocol && sink.Protocol != egv1a1.HTTPProtocol {
360381
return fmt.Errorf("unsupported protocol %s for OpenTelemetry sink, only 'grpc' and 'http' are supported", sink.Protocol)

api/v1alpha1/validation/envoygateway_validate_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,59 @@ func TestValidateEnvoyGatewayXDSServer(t *testing.T) {
11371137
})
11381138
}
11391139

1140+
func TestValidateEnvoyGatewayDebounce(t *testing.T) {
1141+
duration := func(s string) *gwapiv1.Duration {
1142+
d := gwapiv1.Duration(s)
1143+
return &d
1144+
}
1145+
1146+
t.Run("valid no overrides", func(t *testing.T) {
1147+
require.NoError(t, validateEnvoyGatewayDebounce(nil))
1148+
require.NoError(t, validateEnvoyGatewayDebounce(&egv1a1.Debounce{}))
1149+
})
1150+
1151+
t.Run("valid overrides", func(t *testing.T) {
1152+
d := &egv1a1.Debounce{After: duration("100ms"), Max: duration("10s")}
1153+
require.NoError(t, validateEnvoyGatewayDebounce(d))
1154+
})
1155+
1156+
t.Run("valid equal after and max", func(t *testing.T) {
1157+
d := &egv1a1.Debounce{After: duration("1s"), Max: duration("1s")}
1158+
require.NoError(t, validateEnvoyGatewayDebounce(d))
1159+
})
1160+
1161+
t.Run("invalid after duration", func(t *testing.T) {
1162+
d := &egv1a1.Debounce{After: duration("bad")}
1163+
require.Error(t, validateEnvoyGatewayDebounce(d))
1164+
})
1165+
1166+
t.Run("invalid max duration", func(t *testing.T) {
1167+
d := &egv1a1.Debounce{Max: duration("bad")}
1168+
require.Error(t, validateEnvoyGatewayDebounce(d))
1169+
})
1170+
1171+
t.Run("non positive after", func(t *testing.T) {
1172+
d := &egv1a1.Debounce{After: duration("0s")}
1173+
require.Error(t, validateEnvoyGatewayDebounce(d))
1174+
})
1175+
1176+
t.Run("non positive max", func(t *testing.T) {
1177+
d := &egv1a1.Debounce{Max: duration("-1s")}
1178+
require.Error(t, validateEnvoyGatewayDebounce(d))
1179+
})
1180+
1181+
t.Run("max shorter than after", func(t *testing.T) {
1182+
d := &egv1a1.Debounce{After: duration("5s"), Max: duration("1s")}
1183+
require.ErrorContains(t, validateEnvoyGatewayDebounce(d), "must be greater than or equal to")
1184+
})
1185+
1186+
t.Run("max shorter than defaulted after", func(t *testing.T) {
1187+
// After falls back to its 100ms default, so a 10ms max is invalid.
1188+
d := &egv1a1.Debounce{Max: duration("10ms")}
1189+
require.Error(t, validateEnvoyGatewayDebounce(d))
1190+
})
1191+
}
1192+
11401193
func TestDefaultEnvoyGatewayLoggingLevel(t *testing.T) {
11411194
type args struct {
11421195
component string

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/provider/kubernetes/controller.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,17 +187,31 @@ func newGatewayAPIController(ctx context.Context, mgr manager.Manager, cfg *conf
187187
r.client = newNamespaceSelectorClient(r.client, r.namespaceLabel, cfg.ControllerNamespace)
188188
}
189189

190+
debounce := cfg.EnvoyGateway.Debounce
191+
var debounceAfter, debounceMax time.Duration
192+
if debounce.Enabled() {
193+
var err error
194+
if debounceAfter, debounceMax, err = debounce.ResolveDurations(); err != nil {
195+
return err
196+
}
197+
r.log.Info("debouncing reconcile requests", "after", debounceAfter, "max", debounceMax)
198+
}
199+
190200
// controller-runtime doesn't allow run controller with same name for more than once
191201
// see https://github.com/kubernetes-sigs/controller-runtime/blob/2b941650bce159006c88bd3ca0d132c7bc40e947/pkg/controller/name.go#L29
192202
name := fmt.Sprintf("gatewayapi-%d", time.Now().Unix())
193203
c, err := controller.New(name, mgr, controller.Options{
194204
Reconciler: r,
195205
SkipNameValidation: skipNameValidation(),
196206
NewQueue: func(controllerName string, rateLimiter workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] {
197-
return workqueue.NewTypedRateLimitingQueueWithConfig(rateLimiter, workqueue.TypedRateLimitingQueueConfig[reconcile.Request]{
207+
q := workqueue.NewTypedRateLimitingQueueWithConfig(rateLimiter, workqueue.TypedRateLimitingQueueConfig[reconcile.Request]{
198208
Name: controllerName,
199209
MetricsProvider: workqueuemetrics.WorkqueueMetricsProvider{},
200210
})
211+
if !debounce.Enabled() {
212+
return q
213+
}
214+
return newDebouncingQueue(q, debounceAfter, debounceMax)
201215
},
202216
})
203217
if err != nil {

0 commit comments

Comments
 (0)