Skip to content

Commit 5950113

Browse files
authored
feat(metrics): add gate-decision breakdown counter (llm-d#291)
Adds async_gate_decisions_total{queue_id,queue_name,pool_name,reason} (llm-d#217), counting messages a gate prevented from being dispatched, by reason: - gate_closed: dispatch budget exhausted (gate returned Refuse) - quota_exhausted: per-attribute quota overflow (Refuse + overflow class) - dropped: gate permanently rejected the request (Drop) - error: gate evaluation failed Emitted at the gate-application branches in both the redis sorted-set and gcp-pubsub flows. Documented in the README metrics table; unit tested. Related: llm-d#217 Signed-off-by: Shimi Bandiel <shimib@google.com>
1 parent e926d65 commit 5950113

5 files changed

Lines changed: 58 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,7 @@ The Async Processor exposes Prometheus metrics under the `llm_d_async` subsystem
536536
| `llm_d_async_async_queue_residence_time_millis` | Histogram | Time in milliseconds a message spent buffered in-process, from broker ingestion until a worker pulled it for processing. Measures the async delay introduced by the system (queue time). Always registered. |
537537
| `llm_d_async_async_dispatch_budget` | Gauge | Current dispatch budget [0.0–1.0] returned by the queue's gate; the fraction of system capacity available for new requests (0.0 = gate fully closed). Useful for diagnosing why throughput is throttled. |
538538
| `llm_d_async_async_pool_worker_limit` | Gauge | Configured worker concurrency limit for a pool (carries only the `pool_name` label). Compare against `llm_d_async_async_inflight_requests` to compute worker utilization. |
539+
| `llm_d_async_async_gate_decisions_total` | Counter | Count of gate decisions that prevented a message from being dispatched, by `reason`: `gate_closed` (no dispatch budget), `quota_exhausted` (per-attribute quota overflow), `dropped` (gate permanently rejected the request), `error` (gate evaluation failed). |
539540
540541
**Labels:**
541542
@@ -544,6 +545,7 @@ The Async Processor exposes Prometheus metrics under the `llm_d_async` subsystem
544545
| `queue_id` | Transport-level queue identifier |
545546
| `queue_name` | Logical queue name from the queue configuration |
546547
| `pool_name` | Worker pool the queue routes to (`async_pool_worker_limit` carries only this label) |
548+
| `reason` | Gate-decision reason (only on `async_gate_decisions_total`): `gate_closed`, `quota_exhausted`, `dropped`, `error` |
547549
548550
**Example PromQL queries:**
549551

pkg/metrics/metrics.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const (
1515
LabelQueueID = "queue_id"
1616
LabelQueueName = "queue_name"
1717
LabelPoolName = "pool_name"
18+
LabelReason = "reason"
1819
)
1920

2021
var queueLabels = []string{LabelQueueID, LabelQueueName, LabelPoolName}
@@ -98,6 +99,18 @@ var (
9899
Subsystem: SchedulerSubsystem, Name: "async_pool_worker_limit",
99100
Help: "Configured number of concurrent workers (the concurrency limit) for a pool. Compare against async_inflight_requests for worker utilization.",
100101
}, []string{LabelPoolName})
102+
GateDecisions = prometheus.NewCounterVec(prometheus.CounterOpts{
103+
Subsystem: SchedulerSubsystem, Name: "async_gate_decisions_total",
104+
Help: "Count of gate decisions that prevented a message from being dispatched, by reason (gate_closed, quota_exhausted, dropped, error).",
105+
}, []string{LabelQueueID, LabelQueueName, LabelPoolName, LabelReason})
106+
)
107+
108+
// Gate decision reason label values for async_gate_decisions_total.
109+
const (
110+
ReasonGateClosed = "gate_closed"
111+
ReasonQuotaExhausted = "quota_exhausted"
112+
ReasonDropped = "dropped"
113+
ReasonError = "error"
101114
)
102115

103116
func RecordRetry(queueID, queueName, poolName string) {
@@ -174,12 +187,18 @@ func SetPoolWorkerLimit(poolName string, n float64) {
174187
PoolWorkerLimit.WithLabelValues(poolName).Set(n)
175188
}
176189

190+
// RecordGateDecision increments the count of gate decisions that prevented a
191+
// message from being dispatched, labeled by reason.
192+
func RecordGateDecision(reason, queueID, queueName, poolName string) {
193+
GateDecisions.WithLabelValues(queueID, queueName, poolName, reason).Inc()
194+
}
195+
177196
// GetCollectors returns all custom collectors for the async processor.
178197
func GetAsyncProcessorCollectors(supportsMessageLatency bool) []prometheus.Collector {
179198
collectors := []prometheus.Collector{
180199
Retries, AsyncReqs, ExceededDeadlineReqs, FailedReqs, SuccessfulReqs, SheddedRequests,
181200
QueueDepth, InflightRequests, BrokerBacklog, InferenceLatencyTime, QueueResidenceTime,
182-
DispatchBudget, PoolWorkerLimit,
201+
DispatchBudget, PoolWorkerLimit, GateDecisions,
183202
}
184203
if supportsMessageLatency {
185204
collectors = append(collectors, MessageLatencyTime)

pkg/metrics/metrics_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,28 @@ func TestSetPoolWorkerLimit(t *testing.T) {
7676
}
7777
}
7878

79+
func TestRecordGateDecision(t *testing.T) {
80+
RecordGateDecision(ReasonQuotaExhausted, "q9", "queue-9", "pool-z")
81+
RecordGateDecision(ReasonQuotaExhausted, "q9", "queue-9", "pool-z")
82+
RecordGateDecision(ReasonGateClosed, "q9", "queue-9", "pool-z")
83+
84+
if got := testutil.ToFloat64(GateDecisions.WithLabelValues("q9", "queue-9", "pool-z", ReasonQuotaExhausted)); got != 2 {
85+
t.Errorf("GateDecisions[quota_exhausted] = %v, want 2", got)
86+
}
87+
if got := testutil.ToFloat64(GateDecisions.WithLabelValues("q9", "queue-9", "pool-z", ReasonGateClosed)); got != 1 {
88+
t.Errorf("GateDecisions[gate_closed] = %v, want 1", got)
89+
}
90+
}
91+
92+
func TestGetAsyncProcessorCollectors_includesGateDecisions(t *testing.T) {
93+
for _, withLatency := range []bool{false, true} {
94+
collectors := GetAsyncProcessorCollectors(withLatency)
95+
if !containsCollector(collectors, GateDecisions) {
96+
t.Errorf("expected GateDecisions to be present (supportsMessageLatency=%v)", withLatency)
97+
}
98+
}
99+
}
100+
79101
func containsCollector(collectors []prometheus.Collector, target prometheus.Collector) bool {
80102
for _, c := range collectors {
81103
if c == target {

pkg/pubsub/pubsubimpl.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,11 +498,17 @@ func (r *PubSubMQFlow) processMessages(ctx context.Context, receive receiveFunc,
498498
verdict, err := gate.Apply(ctx, ir, &releases)
499499
if err != nil {
500500
logger.V(logutil.DEFAULT).Error(err, "Gating failed")
501+
metrics.RecordGateDecision(metrics.ReasonError, "", subscriberID, poolID)
501502
msg.Nack()
502503
return
503504
}
504505

505506
if verdict.Action == pipeline.ActionRefuse {
507+
reason := metrics.ReasonGateClosed
508+
if ir.GetClassification() == api.ClassificationOverflow {
509+
reason = metrics.ReasonQuotaExhausted
510+
}
511+
metrics.RecordGateDecision(reason, "", subscriberID, poolID)
506512
logger.V(logutil.DEBUG).Info("Quota exceeded or capacity full, delaying Nack", "msgID", msg.ID, "delay", quotaExceededNackDelay)
507513
go func() {
508514
select {
@@ -516,6 +522,7 @@ func (r *PubSubMQFlow) processMessages(ctx context.Context, receive receiveFunc,
516522
}
517523

518524
if verdict.Action == pipeline.ActionDrop {
525+
metrics.RecordGateDecision(metrics.ReasonDropped, "", subscriberID, poolID)
519526
var resultMsg api.ResultMessage
520527
if verdict.Result != nil {
521528
resultMsg = *verdict.Result

pkg/redis/sortedset_impl.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,20 +459,27 @@ func (r *RedisSortedSetFlow) processMessages(ctx context.Context, msgChannel cha
459459
verdict, err := gate.Apply(ctx, ir, &releases)
460460
if err != nil {
461461
logger.V(logutil.DEFAULT).Error(err, "Gating failed")
462+
metrics.RecordGateDecision(metrics.ReasonError, queueID, queueName, poolName)
462463
// Re-enqueue the message on gating failure
463464
member, _ := json.Marshal(ir)
464465
r.rdb.ZAdd(ctx, queueName, redis.Z{Score: deadline, Member: string(member)})
465466
continue
466467
}
467468

468469
if verdict.Action == pipeline.ActionRefuse {
470+
reason := metrics.ReasonGateClosed
471+
if ir.GetClassification() == api.ClassificationOverflow {
472+
reason = metrics.ReasonQuotaExhausted
473+
}
474+
metrics.RecordGateDecision(reason, queueID, queueName, poolName)
469475
// Re-enqueue the message (wait for capacity or quota)
470476
member, _ := json.Marshal(ir)
471477
r.rdb.ZAdd(ctx, queueName, redis.Z{Score: deadline, Member: string(member)})
472478
continue
473479
}
474480

475481
if verdict.Action == pipeline.ActionDrop {
482+
metrics.RecordGateDecision(metrics.ReasonDropped, queueID, queueName, poolName)
476483
if verdict.Result != nil {
477484
r.resultChannel <- *verdict.Result
478485
} else {

0 commit comments

Comments
 (0)