fix(metrics): count gate_closed where budget gating actually happens - #371
Merged
Conversation
shimib
requested review from
RishabhSaini,
ahg-g,
evacchi and
jtechapps
as code owners
July 29, 2026 00:18
shimib
added a commit
that referenced
this pull request
Jul 29, 2026
Signed-off-by: Shimi Bandiel <shimib@google.com>
async_gate_decisions_total{reason="gate_closed"} could never increment for a
budget-based gate. Back-pressure is applied pre-dequeue -- Budget() returns 0.0
and the dequeue batch is sized to zero -- while the counter was only incremented
post-dequeue, inside the loop that batch size had just emptied. gate.Apply() is
the sole caller of RecordGateDecision(gate_closed), so the counter could only
fire in a race where the budget was positive at batch-sizing time and
non-positive microseconds later.
Worse than reading zero: a CounterVec label set that never increments is absent
from /metrics altogether, so the query returns an empty vector, not 0. The one
metric that promises to answer "is the gate shedding work?" was silent exactly
when the gate was doing its job, and operators had to know that
async_dispatch_budget == 0 is the real sentinel.
Record the decision where it is made. The sorted-set path counts a gate_closed
decision on each poll whose batch the budget shrank to zero while the queue is
non-empty (an idle queue was not held back, and ZCARD is only issued on throttled
polls). The Pub/Sub path counts each receive window it skips for the same reason;
there is no cheap depth probe there, since the subscription backlog comes from
Cloud Monitoring. Both queue types also pre-create all four reason series at 0
when the queue starts, so a query for a reason that has not fired yet returns 0
instead of an empty vector.
The counter's unit is decisions, not messages: gate_closed now covers per-message
refusals and throttled dispatch rounds. Help text and the README metric table say
so explicitly.
Fixes #368
Signed-off-by: Shimi Bandiel <shimib@google.com>
Signed-off-by: Shimi Bandiel <shimib@google.com>
shimib
force-pushed
the
fix/gate-closed-counter
branch
from
July 29, 2026 21:14
701293c to
1d44b97
Compare
jtechapps
reviewed
Jul 29, 2026
| // window instead (#368). Unlike Redis there is no cheap depth probe | ||
| // — the subscription backlog comes from Cloud Monitoring — so this | ||
| // counts the window whether or not messages happen to be waiting. | ||
| metrics.RecordGateDecision(metrics.ReasonGateClosed, "", subscriberID, poolID) |
Collaborator
There was a problem hiding this comment.
I am wondering if we should use the gate owner introduced here #372.
Also I realized we are only recording this metric for queue level gates.
Member
Author
There was a problem hiding this comment.
- Gate Owner: Since the dequeue loops (processMessages and requestWorker) are the original sources of truth for these queue/pool labels, I think it's cleaner to use local variables directly rather than polluting the pipeline.Gate interface with an Owner() getter or using type-casting.
- Queue-level Only: Yes, pool-level gates are evaluated post-dequeue in the worker thread where individual queue context is lost. Recording pool-level gate decisions (e.g., omitting queue labels) is a great candidate for post-v0.9 work, but out of scope for this bug fix.
jtechapps
approved these changes
Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #368.
Problem
async_gate_decisions_total{reason="gate_closed"}is structurally pinned at zero for a budget-based gate. The two mechanisms are wired in series and the first starves the second:Back-pressure is applied pre-dequeue by shrinking the batch; the counter was incremented post-dequeue by refusing an already-popped message. The Pub/Sub path has the same shape —
currBatchSize <= 0parks the loop beforeReceivestarts, so the message callback (and itsgate.Apply) never runs.This is worse than a metric that reads zero: a
CounterVeclabel set that has never been incremented is absent from/metricsentirely, so the query returns an empty vector rather than0. The one metric whose help text promises to answer "is the gate shedding work, and why?" was silent exactly when the gate was doing its job, and operators had to know thatasync_dispatch_budget == 0is the real sentinel.Change
Record the decision where it is actually made.
processMessages): when the budget shrinks the batch to zero, count onegate_closeddecision per throttled poll — but only whenZCARD > 0, since an idle queue was not held back.ZCARDis only issued on throttled polls, so there is no cost on the normal path.requestWorker): count each receive window skipped forcurrBatchSize <= 0. There is no cheap depth probe there — the subscription backlog comes from Cloud Monitoring — so this counts the window regardless of what happens to be waiting. The loop re-evaluates every 10s while shut, so the rate is low.metrics.InitGateDecisionscreates all fourreasonseries at 0 when a queue starts, on both backends, so a query for a reason that has not fired yet returns0instead of an empty vector.quota_exhausted/dropped/errorcount individual messages refused after dequeue, andgate_closedcovers those plus every dequeue round the budget emptied. Updated in both the metric help text and the README metric table.Also folded the duplicated
configMappool lookup intopoolNameFor, now that two call sites need it.Effect
For the reported run — gate shut for 18% of samples, queue held at a 185 backlog —
rate(llm_d_async_async_gate_decisions_total{reason="gate_closed"}[5m])is now non-zero throughout, at the poll cadence (500ms by default), giving finer resolution of throttling duration than a 15–30s scrape ofasync_dispatch_budgetcan.async_dispatch_budget == 0remains valid and is still the "right now" view; it is no longer the only signal.Tests
pkg/redis:TestSortedSetFlow_ClosedGateRecordsGateClosedDecision— a closed gate over an empty queue counts nothing; over a backlogged queue each throttled poll counts onegate_closed, no message is dispatched, and the message stays in Redis.pkg/metrics:TestInitGateDecisions— all four reason series exist and read 0 after init.Compatibility
No config or API surface changes. Existing dashboards and alerts on
async_gate_decisions_totalkeep working; series that were previously absent now appear at 0, andgate_closedstarts moving under budget throttling. Anything that treated an absentgate_closedseries as "no throttling" was already wrong, and now gets the real answer.