Skip to content

feat: add MergeBackends option for backend cluster deduplication#9477

Open
muwaqar-cflt wants to merge 85 commits into
envoyproxy:mainfrom
muwaqar:backend-cluster-dedup-rebase
Open

feat: add MergeBackends option for backend cluster deduplication#9477
muwaqar-cflt wants to merge 85 commits into
envoyproxy:mainfrom
muwaqar:backend-cluster-dedup-rebase

Conversation

@muwaqar-cflt

@muwaqar-cflt muwaqar-cflt commented Jul 11, 2026

Copy link
Copy Markdown

What this PR does / why we need it:

Adds an opt-in EnvoyProxy.spec.mergeBackends option: routes referencing the identical backend can share a single Envoy cluster instead of Envoy Gateway generating one cluster per route rule. This reduces xDS size, active health-check traffic, and stats cardinality on gateways with many routes fanning out to a small set of shared backends. Disabled by default and marked experimental.

Cluster sharing is skipped wherever it would be unsafe:

  • multi-backendRef rules using session persistence, ConsistentHash load balancing, or a fallback backend (all need the full backend pool kept together in one cluster, not split per identity)
  • dynamic-resolver/custom (extension-provided) backends
  • a backendRef carrying any per-backendRef filter (e.g. CredentialInjection, header modification) or a UseClientProtocol override, both of which are baked into the shared cluster rather than the per-route destination
  • a route-level BackendTrafficPolicy cluster setting, or a RoutingType diverging from the gateway's baseline
  • TCP/UDP/TLS rules with more than one backendRef, since those route types have no way to represent a rule split across more than one cluster

Approach: enabling this required restructuring how a route's backend data is stored. Previously, every RouteDestination embedded its own copy of the resolved DestinationSettings, so two routes sharing a backend could never share anything at the IR level - deduplication had nowhere to attach. This PR introduces a per-gateway backend cluster registry (ir.Xds.Backends, ir.BackendCluster): each distinct backend is resolved once, and every RouteDestination holds only a BackendClusterRef (a name) into that registry. RouteDestination.Settings is removed once BackendClusterRefs is the sole source.

Beyond enabling MergeBackends, this registry is what makes a future backend-level BackendTrafficPolicy straightforward: since a backend's settings now live in exactly one place instead of being copied into every route that references it, a policy targeting the backend directly can be applied once at the registry entry, rather than needing to be threaded through and reconciled across every route-level copy.

Reviewer notes: this is a large diff; start here:

  • api/v1alpha1/envoyproxy_types.go - the new user-facing MergeBackends/MergeBackendsConfig field
  • internal/ir/xds.go - the BackendCluster/BackendClusterRef types and the Xds.Backends registry
  • internal/gatewayapi/route.go - processBackendRef is the single entry point every route type (and the RequestMirror filter) uses to resolve a backendRef's destination and cluster assignment together. It wraps resolveBackendCluster (decides the cache key/name/merge outcome), shouldMergeBackend (owns the full eligibility decision), mergeIncompatibleForWeightedRule/mergeIncompatibleForSingleClusterRule (the per-rule-level exclusions, split by whether the route type supports weighted-cluster route actions), and getOrCreateBackendCluster (the find-or-create registry lookup)
  • internal/xds/translator/backend_registry.go - registry resolution (getBackendClusters, singleResolvedClusterName) consumed by the HTTP/GRPC route-action and TCP/UDP listener builders, and cluster.go's cluster-per-setting/weight logic

A good portion of the remaining diff is golden testdata: internal/gatewayapi/testdata/*.out.yaml fixtures re-derived to reflect destinations serializing as backendClusterRefs + a top-level backends: registry instead of inline settings:, plus new fixtures (mergebackends-* in internal/gatewayapi/testdata, merge-backends-* in internal/xds/translator/testdata) covering the merge-eligibility rules above at both the IR-building layer and the xDS translation layer - several of the latter are derived directly from the former's real output, tracing a single Gateway API resource all the way through to the generated Envoy config.

Which issue(s) this PR fixes:
Fixes #5307

@netlify

netlify Bot commented Jul 11, 2026

Copy link
Copy Markdown

Deploy Preview for cerulean-figolla-1f9435 ready!

Name Link
🔨 Latest commit fff4d8f
🔍 Latest deploy log https://app.netlify.com/projects/cerulean-figolla-1f9435/deploys/6a5cc75e4c92f80008412f44
😎 Deploy Preview https://deploy-preview-9477--cerulean-figolla-1f9435.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

muwaqar-cflt pushed a commit to muwaqar/gateway that referenced this pull request Jul 11, 2026
Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
@muwaqar-cflt

Copy link
Copy Markdown
Author

/retest

@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.03390% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.60%. Comparing base (2df4a96) to head (31fbfa0).

Files with missing lines Patch % Lines
internal/gatewayapi/backendtrafficpolicy.go 95.30% 4 Missing and 3 partials ⚠️
internal/gatewayapi/ext_service.go 86.66% 2 Missing ⚠️
internal/gatewayapi/route.go 99.12% 2 Missing ⚠️
internal/gatewayapi/securitypolicy.go 88.23% 0 Missing and 2 partials ⚠️
internal/gatewayapi/runner/runner.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9477      +/-   ##
==========================================
+ Coverage   75.41%   75.60%   +0.18%     
==========================================
  Files         252      253       +1     
  Lines       41660    42116     +456     
==========================================
+ Hits        31417    31840     +423     
- Misses       8113     8139      +26     
- Partials     2130     2137       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

muwaqar-cflt pushed a commit to muwaqar/gateway that referenced this pull request Jul 13, 2026
Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
@muwaqar-cflt
muwaqar-cflt force-pushed the backend-cluster-dedup-rebase branch from d5cbd23 to 0f5efab Compare July 13, 2026 00:32
Comment thread internal/gatewayapi/route.go
@muwaqar-cflt
muwaqar-cflt marked this pull request as ready for review July 13, 2026 09:23
@muwaqar-cflt
muwaqar-cflt requested a review from a team as a code owner July 13, 2026 09:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6011972ddb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread internal/xds/translator/route.go Outdated
Comment thread internal/gatewayapi/backendtrafficpolicy.go
Comment thread internal/gatewayapi/backendtrafficpolicy.go Outdated
@zhaohuabing

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31fbfa0fa5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/gatewayapi/route.go Outdated
Comment thread api/v1alpha1/envoyproxy_types.go Outdated
Comment thread internal/gatewayapi/route.go Outdated
Comment thread internal/gatewayapi/backendtrafficpolicy.go Outdated
Comment thread api/v1alpha1/envoyproxy_types.go Outdated
//
// +optional
// +kubebuilder:default=false
Enabled *bool `json:"enabled,omitempty"`

@zhaohuabing zhaohuabing Jul 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to default this to true eventually. The fallback approach won't break current user-facing routing behaviors, and it helps with the Envoy memory issue.

It may be too risky to turn this on by default in 1.9, as it does break EPPs and extension servers, but we could keep it opt-in for the first release and consider flipping the default in 1.10 after we have more coverage and user feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolving because not seeing an action item for now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep it unresolved for now, so other reviewer can see and discuss on this. cc @arkodg @guydc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this flag needed ? mergeBackends: {} should imply we are turning on the feature

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, not necessarily. A bool flag is more explicit than mergeBackends: {}, I'm fine with both.

@zhaohuabing
zhaohuabing requested a review from a team July 17, 2026 08:59
Comment thread internal/gatewayapi/backendtrafficpolicy.go
@cnvergence

Copy link
Copy Markdown
Member

lint fails :(

Comment thread internal/gatewayapi/backendtrafficpolicy.go Outdated
Comment thread internal/gatewayapi/route.go
@zhaohuabing

Copy link
Copy Markdown
Member

This PR looks good. Great work!

I left a few minor comments.

@muwaqar-cflt Could you please fix the link and check whether all e2e tests pass? I’d like to get this merged before 1.9.0 if possible. Thanks!

muwaqar-cflt pushed a commit to muwaqar/gateway that referenced this pull request Jul 17, 2026
Per maintainer feedback on PR envoyproxy#9477, ship only the Enabled boolean for the
first release. Fallback (the only supported behavior) is now unconditional;
Force was already rejected by CEL validation and had no implementation, so
there was no user-visible behavior to preserve by keeping the enum. A
Mode/MergeBackendsMode field can be reintroduced later if a concrete Force
use case emerges.

Regenerated deepcopy, CRDs, and docs; dropped mode: Fallback from all
mergebackends-* input fixtures and their derived golden output; replaced the
CEL validation cases exercising Mode with Enabled-only equivalents.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
muwaqar-cflt pushed a commit to muwaqar/gateway that referenced this pull request Jul 17, 2026
A merged cluster is already scoped to one gateway, so a gateway-level BTP
setting applies uniformly to every route sharing it and can never cause the
divergence this check exists to catch — worth a one-line note for future
readers, per review feedback on PR envoyproxy#9477.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
@@ -133,24 +160,14 @@ xdsIR:
port: 10090
routes:
- destination:
backendClusterRefs:

@arkodg arkodg Jul 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid testdata changes to the default case

muwaqar added 23 commits July 19, 2026 08:24
Add mergebackends-http-demerge-routing-type-divergence covering the
scenario where a route-level BackendTrafficPolicy overrides RoutingType
away from the gateway's baseline: two HTTPRoutes share one backend
identity, a gateway-level BTP sets a baseline RoutingType of Service,
and a route-level BTP on one route overrides it to Endpoint. This
proves routingTypeDivergesForRule correctly keeps the diverging route
on its own route-scoped cluster while the other route still shares the
merged backend-identity-scoped cluster. No production code changes;
this closes a coverage gap identified against a competing PR's fixture
(mergebackends-http-service-routing-not-merged), which uses a
different EnvoyProxy.spec.mergeBackends field shape than this branch.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
Task 5 replaced the two Cluster: backendWeights.Name references
(buildXdsRouteAction, buildXdsURLRewriteAction) with
singleResolvedClusterName(route.Destination), since backendWeights.Name
was the rule-scoped destination name rather than the resolved
BackendCluster name and produced dangling cluster references for merged
backends. That left BackendWeights.Name populated but never read anywhere
(the weighted-clusters path only ever consumes UnavailableWeight()) -
remove it so it can't be mistaken for authoritative and reintroduce the
same bug.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…age and link merge-backends fixtures to real gatewayapi IR

Adds dedicated xds/translator-layer fixtures proving the merged-cluster
cluster-naming fix (from the prior commit) holds for TLS and GRPC, not just
plain HTTP/TCP/UDP, and for the weighted-clusters route-action path (used
when a route's destination needs a per-setting cluster breakdown), not
just the single-cluster path. Also closes a realism gap in all five
protocols' xds/translator fixtures: they previously had only one
route/endpoint each, which didn't exercise "merging" (multiple routes
collapsing onto one cluster) or a realistic multi-endpoint backend.

For each of HTTP/GRPC/TCP/UDP/TLS, adds (or enriches an existing)
gatewayapi-layer mergebackends-<proto>-shared-cluster fixture - a real
Gateway API resource setup proving the IR-building layer merges correctly
- then replaces the corresponding xds/translator fixture's hand-authored
content with the actual IR extracted from that gatewayapi fixture's real
output. This gives a genuine, traceable, end-to-end proof chain (real
K8s-shaped input -> real IR -> real Envoy config) instead of two
independently hand-authored, disconnected layers.

Also adds a mixed-merge regression test pair, at both layers, for a subtle
case: a single rule with backendRefs that have different merge outcomes
(one merges, one or more don't, e.g. because of a per-backendRef filter).
resolveBackendCluster's non-merge branch keys all of a rule's non-merged
backendRefs under the same rule-scoped cache entry, so they land in one
shared gatewayapi-layer BackendCluster "container" - but each retains its
own DestinationSetting.Name, and the xds/translator's cluster-per-setting
splitting (triggered by the per-backendRef filters) correctly gives each
its own real Envoy Cluster and its own entry in the weighted route action,
never collapsing them under the shared container's name. Verified with
both a 2-backendRef (1 merged, 1 filtered) and 3-backendRef (1 merged, 2
differently-filtered) case.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…view

- Rename btpClusterSettingsHasSettings to btpSpecHasClusterScopedFields
  (and its test) - the old name read as testing "ClusterSettings" the
  struct, when it actually checks the whole BackendTrafficPolicySpec for
  any CDS-scoped field.
- Trim ir.BackendCluster.Merged's doc comment to a one-liner, consistent
  with the struct's other fields.
- Rewrite singleResolvedClusterName's comment: frame the registry-lookup
  indirection as xds/translator defensively protecting itself against
  not-necessarily-well-formed data from an earlier layer, rather than
  tracing specific (and driftable) legit code paths that could produce a
  ref-without-registration.
- Rename mergeIncompatibleForRule/mergeIncompatibleForNonWeightedRule to
  mergeIncompatibleForWeightedRule/mergeIncompatibleForSingleClusterRule -
  the old names read as a general case and a negated special case of it,
  when they're actually independent implementations for two route-type
  families distinguished by whether their route action supports weighted
  clusters. Split mergeIncompatibleForSingleClusterRule's combined boolean
  into guard clauses with a comment per condition, matching the sibling
  function's established style.
- mergeIncompatibleForWeightedRule: add a comment to the len(backendRefs)
  <= 1 guard, and restructure the final ConsistentHash condition into an
  explicit if/return true with a trailing return false, for consistency
  with the other guard clauses. Its position (checked before session
  persistence, fallback backend, and ConsistentHash) is intentional and
  unchanged: those three checks protect a multi-backend pool from
  fragmenting, which doesn't apply when there's nothing to fragment.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…ure to BackendClusterRefs schema

This fixture arrived via the sync with upstream main (PR envoyproxy#9199) after this
branch's earlier fixture-migration commits already ran, so it still used
the old inline destination.settings shape instead of
backendClusterRefs/backends. Migrate it the same way; golden output is
byte-for-byte identical, confirming this is a pure schema migration with
no behavior change.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
Found while comparing test coverage against the competing PR envoyproxy#9459, which
has its own TestIsMergeBackendsEnabled - we had none, only indirect
coverage via translator_test.go's fixture harness (which exercises the
function but doesn't specifically pin its precedence behavior). Add
direct coverage for GatewayClass-level vs. default-spec precedence,
including two cases the competing PR's version didn't cover: GatewayClass
EnvoyProxy explicitly disabling MergeBackends still wins over a
default spec that enables it, and a GatewayClass EnvoyProxy with no
MergeBackends field at all correctly falls back to the default spec.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
Cluster.AltStatName (customized via EnvoyProxy.Spec.Telemetry.Metrics.ClusterStatName)
is derived from a route/rule's own identity (route name, rule name/index,
backendRef names) and set on ir.RouteDestination.StatName. asClusterArgs
applied it unconditionally, so a merged BackendCluster - shared by routes
whose ExtraArgs.statName differs - got whichever route's value happened
to register the cluster first, misattributing the other routes' stats.

Same fix shape as the earlier routeHostname leak: fall back to no
AltStatName override for a merged cluster, so Envoy reports stats under
the cluster's own backend-identity name instead of an arbitrary route's.
This is preserved unchanged for the non-merged case, where the
route-derived name remains exactly as before - the field itself stays on
RouteDestination rather than moving to BackendCluster, since a stat name
is inherently rule-scoped (one rule can resolve to several distinct
clusters that already share an identical name today, by design) rather
than tied to any single cluster.

New xds-ir fixture (merge-backends-statname) proves both directions: two
routes with different stat names sharing one merged cluster produce no
AltStatName override, while the existing non-merged stat-name fixture is
unaffected.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
btpSpecHasClusterScopedFields missed AdmissionControl, so a BackendTrafficPolicy
that only sets admissionControl was wrongly treated as CDS-compatible with a
shared/merged cluster, letting its filter leak onto every route sharing that
cluster instead of forcing its own dedicated one.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…usters

A merged BackendCluster's single Setting is shared by every route referencing
it, so its Weight only ever reflects whichever route first registered the
cluster. buildXdsWeightedRouteAction and backendWeights read that cached,
stale Weight directly, so all but the first route to reference a merged
cluster got the wrong weight in weighted route actions and error-response
proportioning.

Resolve weight through the route's own BackendClusterRef instead:
resolvedBackendRef.ResolvedWeight uses ref.Weight for merged clusters and the
setting's own Weight otherwise, and ToBackendWeights aggregates over that.
Also clear the merged cluster's stored Setting.Weight so nothing can read the
stale value directly by mistake.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…namespace

BuildBTPClusterSettingsIndex and BuildBTPLoadBalancerIndex keyed their
btpRoutingKey entries by the policy's own namespace instead of the resolved
target's namespace. For a BTP using cross-namespace targetSelectors (allowed
via a ReferenceGrant), this stored the entry under the wrong namespace, so a
later lookup for the target's actual namespace missed and MergeBackends
wrongly shared a cluster with routes that don't carry that cluster-scoped
policy.

Key by ref.Namespace instead, matching BuildBTPRoutingTypeIndex, which
already does this correctly.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
shouldMergeBackend only checked t.MergeBackends, which IsMergeBackendsEnabled
derives from the GatewayClass-attached EnvoyProxy (or the EnvoyGateway
default) — never from a Gateway-level EnvoyProxy attached via
Gateway.spec.infrastructure.parametersRef. A Gateway wanting to opt in (or
out) of backend cluster deduplication on its own, without changing the
GatewayClass-wide default, had no way to do so.

gatewayCtx.envoyProxy is already the correctly-merged config for this
Gateway (Gateway overriding GatewayClass overriding default, see
GatewayContext.attachEnvoyProxy), and shouldMergeBackend already receives
gatewayCtx, so resolve MergeBackends from there when set, falling back to
t.MergeBackends otherwise.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
Covers EnvoyProxy.spec.mergeBackends end-to-end: two HTTPRoutes referencing
the same backend both route correctly, and Envoy generates a single
deduplicated Cluster for that backend instead of one per route. The
dedup check fetches Envoy's admin /clusters endpoint via port-forward
(collect.RequestWithPortForwarder, already used the same way in
test/benchmark/suite/report.go for pprof).

Uses a Gateway-level EnvoyProxy attachment to enable mergeBackends, per the
Gateway-level override support just added in shouldMergeBackend.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
AddWeighted used receiver w while UnavailableWeight (same type) uses b,
which CI's lint job (staticcheck) flags. Rename to b for consistency.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
Per maintainer feedback on PR envoyproxy#9477, ship only the Enabled boolean for the
first release. Fallback (the only supported behavior) is now unconditional;
Force was already rejected by CEL validation and had no implementation, so
there was no user-visible behavior to preserve by keeping the enum. A
Mode/MergeBackendsMode field can be reintroduced later if a concrete Force
use case emerges.

Regenerated deepcopy, CRDs, and docs; dropped mode: Fallback from all
mergebackends-* input fixtures and their derived golden output; replaced the
CEL validation cases exercising Mode with Enabled-only equivalents.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
A merged cluster is already scoped to one gateway, so a gateway-level BTP
setting applies uniformly to every route sharing it and can never cause the
divergence this check exists to catch — worth a one-line note for future
readers, per review feedback on PR envoyproxy#9477.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
processClientCertificates ran once per route referencing a backend and added
a secret via tCtx.AddXdsResource directly, which has no dedup by name (unlike
addXdsSecret). Multiple routes sharing one backend's client certificate (e.g.
a SecurityPolicy's extAuth destination attached to more than one route, or a
BackendTLS client cert shared across routes) produced one duplicate secret
resource per extra route referencing it.

This was previously masked in the ext-auth-backend fixture by two routes'
extAuth destinations having artificially different, hand-authored settings
under the old inline-settings IR schema despite sharing a name; migrating to
the BackendClusterRefs registry made both routes correctly resolve to the
one real shared backend, surfacing the latent duplicate-add bug.

Route to addXdsSecret, which already dedupes by name.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…adata

getOrCreateBackendCluster set a merged cluster's Metadata to whichever
route's routeRuleMetadata happened to register the cluster first - the
same first-registered-wins hazard already fixed for StatName, Hostname,
and Weight. Since the cluster is shared across every route referencing
that backend, this metadata is both semantically wrong (implies the
cluster belongs to one specific route) and a source of unnecessary xDS
churn (it can flip to a different route's identity as routes are added,
removed, or reordered, despite the backend itself never changing).

ds.Metadata already holds the backend's own resource identity
(Service or Backend CR, built independent of any calling route in
processServiceDestinationSetting/processBackendDestinationSetting), so use
that for merged clusters instead - a stable value tied to the backend, not
to whichever route asks first.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…-direct-source-ip fixture

Migrating this fixture to the BackendClusterRefs registry schema dropped the
route's Metadata (previously set directly on RouteDestination) since the new
top-level backends: entry never had it added. This is a route-scoped (non-
merged) cluster, so its Cluster metadata should identify the owning route,
same as every other non-merged cluster - restore it to match the
pre-migration baseline exactly.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
IsConsistentHash has exactly one caller (mergeIncompatibleForWeightedRule),
which only reaches it after hasRouteLevelClusterSettings is already false
for the same target. Since btpSpecHasClusterScopedFields treats any
LoadBalancer as a cluster-scoped field (not just ConsistentHash), that
precondition already guarantees no route-rule/route/listener-level BTP sets
LoadBalancer at all - making those three levels of BTPLoadBalancerIndex dead
code at its one call site.

Collapse the index to a single gateway-level map, precomputed as a bool
(is-ConsistentHash) rather than storing the full *LoadBalancer and
re-checking .Type on every lookup. Shrink IsConsistentHash's signature to
just the gateway identity it now needs, and drop the parentRef/ruleName
params from mergeIncompatibleForWeightedRule that existed solely to build
the old signature's route-rule key.

Rewrite TestBTPLoadBalancerIndexIsConsistentHash: drop the two cases that
tested now-removed route-rule/route precedence behavior, and re-point the
cross-namespace case at a Gateway target (the only level left) instead of a
Route.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
mergeIncompatibleForWeightedRule mixed the len(backendRefs) <= 1 guard with
the session-persistence/fallback/ConsistentHash checks it guards. Extract
the latter into weightedRuleBackendsMustBeInOneCluster (named for the
HTTP/GRPC-only weighted-rule path, mirroring
mergeIncompatibleForSingleClusterRule's naming for the TCP/UDP/TLS path) so
the guard and the checks it protects read as separate concerns. Pure
extraction, no behavior change.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
…dentity

resolveBackendCluster used t.getIRKey(gatewayCtx.Gateway) for a merged
cluster's key and name, which collapses to the GatewayClass-wide identity
when MergeGateways is enabled. Two Gateways sharing one Envoy deployment via
MergeGateways can have different gateway-scoped BackendTrafficPolicy
settings; if they also reference the same backend under MergeBackends, they
were incorrectly merged into one shared cluster, and the CDS cluster name
collision meant whichever route was processed first silently won -
addXdsCluster skips rebuilding a cluster that already exists by name, so the
other Gateway's settings (health check, circuit breaker, LB policy, etc.)
were dropped rather than applied.

Always key/name a merged cluster by the Gateway's own raw namespace/name,
never the MergeGateways-collapsed one. When MergeGateways is disabled this
is exactly what getIRKey already returned, so no existing fixture changes;
irBackendClusterName only appends the Gateway identity when MergeGateways is
enabled, keeping cluster names unchanged for the common case.

Adds coverage at three levels: a resolveBackendCluster unit test proving two
Gateways get different keys and names, a gatewayapi Translate() fixture with
two Gateways carrying different gateway-scoped BackendTrafficPolicy
CircuitBreaker settings on the same backend, and an xds/translator fixture
proving both Clusters build with their own distinct settings rather than one
silently winning.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
Cover the MergeGateways+MergeBackends combination: two Gateways sharing
one Envoy deployment, each with its own gateway-scoped
BackendTrafficPolicy, routing to the same backend. Verifies each
Gateway keeps its own Cluster with its own settings instead of
colliding into one shared Cluster.

Also extract the admin /clusters port-forward/fetch logic shared by
MergeBackendsTest and the new test into fetchEnvoyClustersOutput.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
Rebasing onto main pulled in new tests whose golden fixtures still used
the pre-BackendClusterRefs-migration schema (inline destination
settings instead of backendClusterRefs + a top-level backends
registry). Regenerated the gatewayapi-layer fixtures via
-override-testdata, and hand-migrated the one xds-ir input fixture
(host-normalization.yaml) that -override-testdata can't rewrite itself.

Signed-off-by: Muhammad Waqar <waqar.hameed08@gmail.com>
@muwaqar-cflt
muwaqar-cflt force-pushed the backend-cluster-dedup-rebase branch from f3bc58b to fff4d8f Compare July 19, 2026 12:47
Comment on lines 1855 to +1869
for _, rule := range tlsRoute.Spec.Rules {
gatewayCtx, btpRoutingType, hasRouteLevelClusterSettings := t.resolveRoutingContext(tlsRoute, parentRef, rule.Name)
gwIR := t.gatewayXdsIR(gatewayCtx, xdsIR)
mergeIncompatible := mergeIncompatibleForSingleClusterRule(hasRouteLevelClusterSettings, len(rule.BackendRefs))
for i := range rule.BackendRefs {
settingName := irDestinationSettingName(destName, i)
backendRefCtx := DirectBackendRef{BackendRef: &rule.BackendRefs[i]}
// ds will never be nil here because processDestination returns an empty DestinationSetting for invalid backendRefs.
ds, _, err := t.processDestination(settingName, backendRefCtx, parentRef, tlsRoute, resources, rule.Name)
ds, cluster, _, err := t.processBackendRef(destName, i, backendRefCtx, parentRef, tlsRoute, resources, gatewayCtx, btpRoutingType, xdsIR, mergeIncompatible)
if err != nil {
resolveErrs.Add(err)
continue
}
// skip backendRefs with weight 0 as they do not affect the traffic distribution
if ds.Weight != nil && *ds.Weight > 0 {
destSettings = append(destSettings, ds)
backendCluster := t.getOrCreateBackendCluster(gwIR, cluster.Key, cluster.Name, cluster.Merge, ds, routeRuleMetadata)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arkodg Splitting the Codex findings into individual threads for tracking, so each can be resolved independently as it's fixed.

Multi-rule TLSRoute can reference a nonexistent cluster

Merge safety is decided independently per rule here, but a TLSRoute can have up to 16 rules — two rules that each have a single backend can each look mergeable on their own. That produces one TCP proxy route referencing multiple backend-cluster names, and singleResolvedClusterName() falls back to a route-scoped name that was never actually created as a Cluster — so the listener ends up referencing a missing cluster.

Merge compatibility needs to be computed across the whole TLSRoute, not independently per rule.

Comment on lines +591 to +621
func (t *Translator) shouldMergeBackend(
gatewayCtx *GatewayContext,
btpRoutingType *egv1a1.RoutingType,
mergeIncompatible bool,
backendRef gwapiv1.BackendObjectReference,
backendNamespace string,
ds *ir.DestinationSetting,
) bool {
// Custom/extension-provided and dynamic-resolver backends can never safely share a merged cluster.
if !t.isMergeableBackendKind(backendRef, backendNamespace) {
return false
}
// A rule-wide reason (route-level cluster settings, session persistence, fallback backend,
// ConsistentHash) already makes merging unsafe for every backendRef in this rule.
if mergeIncompatible {
return false
}
// A merged cluster keeps only the first-registered backendRef's Filters (getOrCreateBackendCluster
// never appends a second Settings entry when merge=true), so any backendRef carrying filters that
// could legitimately differ per-backendRef (header modification, URL rewrite, CredentialInjection,
// etc.) must never share a cluster with a differently-configured one.
if ds.Filters != nil {
return false
}
if !t.isMergeBackendsEnabledForGateway(gatewayCtx) {
return false
}

return !t.routingTypeDivergesForRule(gatewayCtx, btpRoutingType)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arkodg

Listener-specific ClientTrafficPolicy settings leak or disappear

shouldMergeBackend doesn't account for listener-specific HTTP settings (e.g. preserveHeaderCase). If two listeners with different listener-level HTTP settings both route to the same backend, they end up sharing one merged cluster; whichever listener's route gets processed first wins (addXdsCluster's existing-cluster-name skip), silently dropping or leaking the other listener's settings depending on processing order.

Effective listener HTTP settings need to either be folded into the merge identity, or make the backend ineligible for merging.

Comment on lines +1422 to +1429
func (httpRoute *HTTPRouteTranslator) asClusterArgs(bc *ir.BackendCluster, extra *ExtraArgs) *xdsClusterArgs {
// A merged cluster may be shared by routes with different hostnames or stat names, so no
// single route's value reliably describes it — fall back to none rather than an arbitrary one.
routeHostname := httpRoute.Hostname
statName := extra.statName
if bc.Merged {
routeHostname = ""
statName = nil

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arkodg

Merging can break active HTTP health checks

For every merged cluster, this clears the route hostname unconditionally (routeHostname = ""), even when only one route currently references the cluster. An HTTP health check without an explicit Host previously used that route hostname; once merged, no Host is emitted, so Envoy falls back to the cluster name instead — a virtual-host-sensitive health endpoint can return 404 and mark the backend unhealthy.

Should only merge when the health-check Host is explicit/invariant across the merged routes, or keep separate clusters when route hostname affects health checking.

Comment on lines +1422 to +1429
func (httpRoute *HTTPRouteTranslator) asClusterArgs(bc *ir.BackendCluster, extra *ExtraArgs) *xdsClusterArgs {
// A merged cluster may be shared by routes with different hostnames or stat names, so no
// single route's value reliably describes it — fall back to none rather than an arbitrary one.
routeHostname := httpRoute.Hostname
statName := extra.statName
if bc.Merged {
routeHostname = ""
statName = nil

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arkodg

clusterStatName is silently ignored for merged clusters

Same block also clears statName unconditionally for merged clusters, so a configured EnvoyProxy.spec.telemetry.metrics.clusterStatName stops producing alt_stat_name once merging is enabled.

Should either prevent merging when a stat name is configured, use a documented backend-based stat name, or explicitly reject/document this as an incompatible combination rather than silently discarding it.

@arkodg

arkodg commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

The current shape makes the backend registry part of every translation, even when mergeBackends is unset. In a three-sample local run of the large translator benchmark with the option unset, this branch was about +4.1% ns/op, +1.9% B/op, and +1.4% allocs/op versus the base branch.

I think we can keep the universal IR registry while making merge-specific work pay for itself:

  1. Keep the default path local. Build the nonmerged BackendCluster in the route-rule builder, append its destination settings there, and add it to the registry once. Use BackendClusterMap only for shared clusters and initialize it lazily. This should also let us remove ResolvedBackendCluster, routeScopedAdded, and the route-specific fields from the shared key.
  2. Exit earlier when merging is disabled. Check the class/Gateway setting at the start of shouldMergeBackend, before custom-backend lookup, filter inspection, and policy compatibility checks. Only build the merge-safety BackendTrafficPolicy indexes if an accepted Gateway can enable merging, and resolve each policy's targets once rather than once per index.
  3. Look up shared backends before fully resolving them. processBackendRef currently runs processDestination before consulting the registry, so endpoint, TLS, and destination work is repeated and then discarded on a shared-cache hit. Compute the stable identity first and cache the invariant resolution.
  4. Deduplicate earlier in xDS translation. Check an O(1) emitted-cluster set before calling asClusterArgs. The current path builds the arguments, applies traffic policy, and then performs a linear scan to discover that the cluster already exists.
  5. Resolve route backend refs once. Pass one resolved {refs, clusters, weights, singleName} result through route and cluster generation instead of repeatedly looking up and allocating the same slices.
  6. Make missing registry references explicit errors. The synthesized empty backend and rd.Name fallback make invariant violations harder to detect. Once all fixtures use the registry, failing at the translator boundary would be simpler and safer.

The first two changes are the ones I would prioritize in this PR because they directly address the measured cost when the feature is not enabled. The remaining items could be follow-ups if keeping this change smaller is preferable.

Comment thread internal/ir/xds.go
// and its endpoint configuration. Multiple routes sharing the same backend can reference the
// same BackendCluster.
// +kubebuilder:object:generate=true
type BackendCluster struct {

@zhaohuabing zhaohuabing Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using BackendCluster only for the merged case, and keeping inline Settings on RouteDestination for non-merged.

Right now BackendCluster serves double duty — merged (shared, in Xds.Backends) and non-merged (route-scoped) — carried by a Merged flag. That makes the xDS translation layer harder to follow and it churns every non-merged golden fixture.

I think we can confine the new machinery to the merged path:

type RouteDestination struct {
      Name     string  `json:"name" yaml:"name"`
      StatName *string `json:"statName,omitempty" yaml:"statName,omitempty"`

      // A destination is either route-local (Settings) or merged (BackendClusterRefs) — never
      // both. Both empty means it has no resolvable backend. 
      //
      // Settings holds the endpoints of this route's OWN, non-merged cluster (the default case).
      // A rule's backendRefs collapse into this one route-scoped cluster, so it can hold multiple
      // settings. It is never shared with another route.
      Settings []*DestinationSetting `json:"settings,omitempty" yaml:"settings,omitempty"`

      // BackendClusterRefs references shared, identity-deduplicated clusters in Xds.Backends (the
      // merged case) — one ref per backend. Each referenced cluster is a single backend identity
      // and may be shared across routes; its data lives in the registry.
      BackendClusterRefs []*BackendClusterRef `json:"backendClusterRefs,omitempty" yaml:"backendClusterRefs,omitempty"`

      IsDynamicResolver bool              `json:"isDynamicResolver,omitempty" yaml:"isDynamicResolver,omitempty"`
      Metadata          *ResourceMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}

// A shared, identity-deduplicated backend cluster: one backend identity, one setting.
type BackendCluster struct {
      Name     string
      Setting  *DestinationSetting   // singular — holding the endpoints for a shared backend
      Metadata *ResourceMetadata
}

Benefits:

  • Non-merged IR and xDS output revert to their previous shape → non-merged fixtures stay untouched, so the diff shows only the merged feature.
  • Merged becomes structural ("in the registry ⇔ merged") rather than a flag, so the type stops doing double duty.
  • len(rd.BackendClusterRefs) > 1 becomes unambiguous — non-merged has zero refs, so > 1 cleanly means "merged multi-backend."
  • Easier to review since the number of changed files would be much less.

WDYT? @muwaqar-cflt @arkodg ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Revisit Kubernetes Services mapping to Envoy v3.Cluster

5 participants