Skip to content

fix: notify cross-namespace routes when a QuotaPolicy is deleted - #2770

Open
vignesh-chaturvedi wants to merge 3 commits into
theagentrouter:mainfrom
vignesh-chaturvedi:fix/quota-delete-cross-namespace-routes
Open

vignesh-chaturvedi wants to merge 3 commits into
theagentrouter:mainfrom
vignesh-chaturvedi:fix/quota-delete-cross-namespace-routes

Conversation

@vignesh-chaturvedi

Copy link
Copy Markdown
Contributor

Description

When a QuotaPolicy is deleted, the controller re-reconciles only the
AIGatewayRoutes in the policy's own namespace: notifyAllAIGatewayRoutesInNamespace
lists routes with client.InNamespace. An update goes through
notifyAIGatewayRoutes instead, which finds routes via the
BackendToReferencingAIGatewayRoute index. That index is keyed on the backend's
namespace, so it reaches routes in any namespace that reference a targeted
backend through a ReferenceGrant.

A route in namespace B that references a backend in namespace A is therefore
notified when a QuotaPolicy in A changes, but not when that policy is deleted.
It keeps the deleted policy's rate_limits actions until something else
re-translates it. Deleting the policy already removes its limits from the rate
limit service, so nothing is enforced, but Envoy keeps calling the rate limit
service on every request and at stream-done for descriptors that no longer have
a limit. With --quotaRateLimitFailureModeDeny set, a rate limit service outage
would also reject requests on a route that no longer has any quota.

This changes the deletion path to list the AIServiceBackends in the policy's
namespace and notify through the same index the update path uses. The policy's
targetRefs are gone by the time the deletion is observed, so the backends in its
namespace stand in for them. That is a superset of what the policy could have
targeted, since QuotaPolicy targetRefs are local. Routes in the policy's
namespace that reference no backend there are no longer notified, which is fine
because a QuotaPolicy cannot affect them.

Related Issues/PRs (if applicable)

Related: #2550
Related PR: #2561

Special notes for reviewers (if applicable)

This is the follow-up I offered in my review on #2561. That PR stamps a hash of
the targeting QuotaPolicies onto the derived HTTPRoute so Envoy Gateway
re-translates it, which only helps a route that gets re-reconciled in the first
place. Without this change, a cross-namespace route also keeps the deleted
policy's entry in that annotation. The two PRs touch different files and can
land in either order.

TestQuotaPolicyController_Reconcile_DeletionNotifiesCrossNamespaceRoutes
creates a backend and a QuotaPolicy in ns-a, one route in ns-a, and one in
ns-b that references the backend across namespaces. It asserts that an update
notifies both routes, then that the deletion does too. On main it fails with
ns-b/remote-route missing from the deletion events, and it passes with this
change. The existing deletion test did not check which routes were notified.

If a backend is deleted before its QuotaPolicy, this path no longer finds the
routes that referenced it. Those routes are already re-reconciled by the
AIServiceBackend controller when the backend goes away, through the same index.

make precommit is clean and go test ./internal/controller/... passes. I have
not run this against a live cluster, so the notification is verified at the
controller level only.

I used an AI assistant while investigating and writing this change, noted per
the generative AI policy in the contributing guide. I have reviewed and
understand the diff and can revise it in review.

On deletion the QuotaPolicy controller notified only AIGatewayRoutes in the
policy's own namespace, while an update reaches every route that references a
targeted backend through the BackendToReferencingAIGatewayRoute index. A route
in another namespace that references the backend through a ReferenceGrant was
therefore never re-reconciled when the policy was deleted. It kept the deleted
policy's rate_limits actions, so Envoy went on calling the rate limit service
for descriptors that no longer have a limit, and with
quotaRateLimitFailureModeDeny a rate limit service outage would reject
requests on a route that no longer has any quota.

Deletion now lists the AIServiceBackends in the policy's namespace and
notifies through the same index the update path uses.

Signed-off-by: Vignesh Chaturvedi <vigneshchaturvedi@gmail.com>
@vignesh-chaturvedi
vignesh-chaturvedi requested a review from a team as a code owner October 1, 2026 10:37
@netlify

netlify Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for theagentrouter canceled.

Name Link
🔨 Latest commit 88e2cb0
🔍 Latest deploy log https://app.netlify.com/projects/theagentrouter/deploys/6ac02649e1666e00089fd049

@codecov

codecov Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/controller/quota_policy.go 95.65% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@aabchoo aabchoo added area/controller Controller and reconciliation area/quota Rate limiting, quota, token/cost accounting and attribution labels Oct 1, 2026
Comment thread internal/controller/quota_policy.go Outdated
"route", route.Name, "namespace", route.Namespace)
c.aiGatewayRouteChan <- event.GenericEvent{Object: route}
for i := range backends.Items {
key := fmt.Sprintf("%s.%s", backends.Items[i].Name, namespace)

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.

do you mind making a helper function that does this so we don't duplicate this logic all over ie in syncAIServiceBackend?

ie

  func namespacedNameIndexKey(name, namespace string) string {
      return fmt.Sprintf("%s.%s", name, namespace)
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Added namespacedNameIndexKey(name, namespace) in controller.go and used
it on both sides of the two indexes this change relies on: aiGatewayRouteIndexFunc
and quotaPolicyTargetRefsIndexFunc, which produce the keys, and
syncAIServiceBackend, BackendToQuotaPolicy, notifyAIGatewayRoutes and the
deletion path, which look them up.

There are about a dozen more "%s.%s" keys in the gateway, MCP route and secret
indexes, plus the existing backendSecurityPolicyKey. I left those alone to keep
this PR focused on the fix, but I am happy to sweep them in a follow-up if you
would like.

c.aiGatewayRouteChan <- event.GenericEvent{Object: route}
for i := range backends.Items {
key := fmt.Sprintf("%s.%s", backends.Items[i].Name, namespace)
var aiGatewayRoutes aigv1b1.AIGatewayRouteList

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.

could you add a dedupe check to avoid calling the same aigatewayroute multiple times?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Each route is now notified once per deletion, tracked by namespaced name.
The test gives local-route references to two backends in the policy's
namespace, so it fails if the dedupe is removed.

Comment thread internal/controller/quota_policy.go Outdated
// notifyAIGatewayRoutesForNamespace sends events for every AIGatewayRoute that
// references an AIServiceBackend in the given namespace, wherever the route lives.
// Used on QuotaPolicy deletion when targetRefs are no longer available.
func (c *QuotaPolicyController) notifyAIGatewayRoutesForNamespace(ctx context.Context, namespace string) {

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.

Thoughts on adding a returning an error? Any premature returns will result in the envoy configs not updating properly and the issue you mentioned will still persist

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, done. The function now returns the error and Reconcile passes it
through, so controller-runtime requeues. Retrying is safe:
deleteQuotaPolicyConfig is idempotent, and a repeated notification just
re-reconciles the route. TestQuotaPolicyController_Reconcile_DeletionReturnsNotifyError
injects a failing list and checks that the error comes back.

notifyAIGatewayRoutes on the update path still logs and continues on the same
kind of error. Want me to make it return the error too, here or in a follow-up?

Address review on the deletion path:

- Return list errors from notifyAIGatewayRoutesForNamespace so a failed
  lookup requeues the reconcile instead of leaving routes un-notified.
- Notify each AIGatewayRoute once, even when it references several
  AIServiceBackends in the policy's namespace.
- Add namespacedNameIndexKey and use it on both sides of the
  backend-to-route and backend-to-QuotaPolicy indexes, including
  syncAIServiceBackend, instead of repeating the key format.

Signed-off-by: Vignesh Chaturvedi <vigneshchaturvedi@gmail.com>
@aabchoo

aabchoo commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

I am seeing that this case is being covered in #2766.

@vignesh-chaturvedi

Copy link
Copy Markdown
Contributor Author

Thanks, you're right that #2766 covers the cross-namespace case: it notifies from
the finalizer callback while the targetRefs still exist, and the hash skips
terminating policies. I'm happy to close this in its favour.

One difference first. #2766 also removes the cleanup from the not-found branch
of Reconcile, so a QuotaPolicy that disappears without going through the
finalizer callback keeps its limits in the rate limit config until the
controller restarts. I left the details and how I reproduced it on #2766. If it
is easier to keep that safety net as a small PR on top of #2766, I can cut this
one down to just that.

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

Labels

area/controller Controller and reconciliation area/quota Rate limiting, quota, token/cost accounting and attribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants