Skip to content

Commit 9473067

Browse files
committed
add subscriber and pool health metrics
Signed-off-by: satyamg1620 <Satyam.Gupta.3@ibm.com>
1 parent 2e76514 commit 9473067

6 files changed

Lines changed: 286 additions & 11 deletions

File tree

pkg/kvcache/metrics/collector.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import (
3333
// aliases so existing scrapers keep working during the migration.
3434
const routerSubsystem = "llm_d_router_epp"
3535

36+
// podIdentifierLabel is the label key carried by the per-pod kvevents metrics.
37+
// Keeping it as a constant localizes label-schema changes to this package.
38+
const podIdentifierLabel = "pod_identifier"
39+
3640
// dualCounter emits a value to both the deprecated kvcache_* counter and the
3741
// current llm_d_router_epp_* counter. It satisfies prometheus.Collector so both
3842
// register, and exposes the recording/read methods the call sites use.
@@ -130,6 +134,53 @@ var (
130134
DedupRemovedHashesForwarded = newDualCounter("kvevents", "dedup_removed_hashes_forwarded_total",
131135
"kv_cache_events_dedup_removed_hashes_forwarded_total",
132136
"Block hashes forwarded for eviction after the KV-event dedup filter (block hashes, not BlockRemoved events)")
137+
138+
// SubscriberActive tracks the number of ZMQ subscribers currently managed by
139+
// the SubscriberManager. A subscriber is counted from creation until it is
140+
// removed, so the gauge includes subscribers that are retrying a failed
141+
// connection; it is a measure of intent, not of live connectivity. Pair it
142+
// with SubscriberReconnections and ZMQErrors to spot unhealthy subscribers.
143+
SubscriberActive = prometheus.NewGauge(prometheus.GaugeOpts{
144+
Subsystem: routerSubsystem, Name: "kv_cache_events_active_subscribers",
145+
Help: metricsutil.HelpMsgWithStability(
146+
"Number of ZMQ subscribers currently managed, including those retrying a failed connection",
147+
compbasemetrics.ALPHA),
148+
})
149+
// SubscriberReconnections counts ZMQ subscriber reconnection attempts,
150+
// labeled by the pod identifier the subscriber is bound to.
151+
SubscriberReconnections = prometheus.NewCounterVec(prometheus.CounterOpts{
152+
Subsystem: routerSubsystem, Name: "kv_cache_events_subscriber_reconnections_total",
153+
Help: metricsutil.HelpMsgWithStability(
154+
"Total number of ZMQ subscriber reconnection attempts", compbasemetrics.ALPHA),
155+
}, []string{podIdentifierLabel})
156+
// MessagesReceived counts raw messages received from ZMQ subscribers. The
157+
// rate of message ingestion can be derived via rate() in Prometheus.
158+
MessagesReceived = prometheus.NewCounterVec(prometheus.CounterOpts{
159+
Subsystem: routerSubsystem, Name: "kv_cache_events_messages_received_total",
160+
Help: metricsutil.HelpMsgWithStability(
161+
"Total number of messages received from ZMQ subscribers", compbasemetrics.ALPHA),
162+
}, []string{podIdentifierLabel})
163+
// ZMQErrors counts errors encountered by ZMQ subscribers, labeled by the
164+
// pod identifier and the operation that failed (e.g. "connect", "recv").
165+
ZMQErrors = prometheus.NewCounterVec(prometheus.CounterOpts{
166+
Subsystem: routerSubsystem, Name: "kv_cache_events_zmq_errors_total",
167+
Help: metricsutil.HelpMsgWithStability(
168+
"Total number of ZMQ subscriber errors", compbasemetrics.ALPHA),
169+
}, []string{podIdentifierLabel, "operation"})
170+
// PoolQueueDepth tracks the total number of messages queued across all
171+
// worker shards of the event processing pool.
172+
PoolQueueDepth = prometheus.NewGauge(prometheus.GaugeOpts{
173+
Subsystem: routerSubsystem, Name: "kv_cache_events_pool_queue_depth",
174+
Help: metricsutil.HelpMsgWithStability(
175+
"Total number of messages queued across all worker shards", compbasemetrics.ALPHA),
176+
})
177+
// PoolCapacity tracks the number of worker shards (capacity) configured for
178+
// the event processing pool.
179+
PoolCapacity = prometheus.NewGauge(prometheus.GaugeOpts{
180+
Subsystem: routerSubsystem, Name: "kv_cache_events_pool_capacity",
181+
Help: metricsutil.HelpMsgWithStability(
182+
"Number of worker shards (capacity) in the event processing pool", compbasemetrics.ALPHA),
183+
})
133184
)
134185

135186
// Collectors returns a slice of all registered Prometheus collectors.
@@ -138,9 +189,21 @@ func Collectors() []prometheus.Collector {
138189
Admissions, Evictions,
139190
LookupRequests, LookupHits, LookupLatency, MaxPodHitCount,
140191
DedupRemovedHashesSuppressed, DedupRemovedHashesForwarded,
192+
SubscriberActive, SubscriberReconnections, MessagesReceived, ZMQErrors,
193+
PoolQueueDepth, PoolCapacity,
141194
}
142195
}
143196

197+
// CleanupSubscriber drops every per-pod kvevents series for podIdentifier so
198+
// stale time series do not linger after a subscriber is removed. Callers must
199+
// invoke it only once the subscriber's goroutine has exited, otherwise a late
200+
// increment resurrects the series.
201+
func CleanupSubscriber(podIdentifier string) {
202+
SubscriberReconnections.DeleteLabelValues(podIdentifier)
203+
MessagesReceived.DeleteLabelValues(podIdentifier)
204+
ZMQErrors.DeletePartialMatch(prometheus.Labels{podIdentifierLabel: podIdentifier})
205+
}
206+
144207
var registerMetricsOnce = sync.Once{}
145208

146209
// Register registers all metrics with K8s registry.

pkg/kvcache/metrics/collector_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ func TestCollectorsIncludesAllMetrics(t *testing.T) {
5252
{"MaxPodHitCount", MaxPodHitCount},
5353
{"DedupRemovedHashesSuppressed", DedupRemovedHashesSuppressed},
5454
{"DedupRemovedHashesForwarded", DedupRemovedHashesForwarded},
55+
{"SubscriberActive", SubscriberActive},
56+
{"SubscriberReconnections", SubscriberReconnections},
57+
{"MessagesReceived", MessagesReceived},
58+
{"ZMQErrors", ZMQErrors},
59+
{"PoolQueueDepth", PoolQueueDepth},
60+
{"PoolCapacity", PoolCapacity},
5561
}
5662

5763
for _, e := range expected {
@@ -61,6 +67,92 @@ func TestCollectorsIncludesAllMetrics(t *testing.T) {
6167
}
6268
}
6369

70+
func TestKVEventsMetricNames(t *testing.T) {
71+
// The kvevents observability metrics must be emitted under the router EPP
72+
// subsystem with the kv_cache_events prefix.
73+
reg := prometheus.NewRegistry()
74+
kvevents := []prometheus.Collector{
75+
SubscriberActive, SubscriberReconnections, MessagesReceived,
76+
ZMQErrors, PoolQueueDepth, PoolCapacity,
77+
}
78+
for _, c := range kvevents {
79+
reg.MustRegister(c)
80+
}
81+
82+
// Emit a sample for each labeled metric so it appears in the gather output.
83+
SubscriberActive.Set(1)
84+
SubscriberReconnections.WithLabelValues("pod-a").Inc()
85+
MessagesReceived.WithLabelValues("pod-a").Inc()
86+
ZMQErrors.WithLabelValues("pod-a", "recv").Inc()
87+
PoolQueueDepth.Set(3)
88+
PoolCapacity.Set(4)
89+
90+
mfs, err := reg.Gather()
91+
if err != nil {
92+
t.Fatalf("Gather() failed: %v", err)
93+
}
94+
95+
got := make(map[string]bool, len(mfs))
96+
for _, mf := range mfs {
97+
got[mf.GetName()] = true
98+
}
99+
100+
for _, name := range []string{
101+
"llm_d_router_epp_kv_cache_events_active_subscribers",
102+
"llm_d_router_epp_kv_cache_events_subscriber_reconnections_total",
103+
"llm_d_router_epp_kv_cache_events_messages_received_total",
104+
"llm_d_router_epp_kv_cache_events_zmq_errors_total",
105+
"llm_d_router_epp_kv_cache_events_pool_queue_depth",
106+
"llm_d_router_epp_kv_cache_events_pool_capacity",
107+
} {
108+
if !got[name] {
109+
t.Errorf("expected metric %q to be registered, got names: %v", name, got)
110+
}
111+
}
112+
}
113+
114+
func TestCleanupSubscriberDropsPerPodSeries(t *testing.T) {
115+
// Removing a subscriber must drop only its own series, leaving other pods'
116+
// series intact.
117+
SubscriberReconnections.WithLabelValues("pod-a").Inc()
118+
MessagesReceived.WithLabelValues("pod-a").Inc()
119+
ZMQErrors.WithLabelValues("pod-a", "recv").Inc()
120+
ZMQErrors.WithLabelValues("pod-a", "connect").Inc()
121+
SubscriberReconnections.WithLabelValues("pod-b").Inc()
122+
MessagesReceived.WithLabelValues("pod-b").Inc()
123+
ZMQErrors.WithLabelValues("pod-b", "recv").Inc()
124+
125+
CleanupSubscriber("pod-a")
126+
127+
reg := prometheus.NewRegistry()
128+
reg.MustRegister(SubscriberReconnections, MessagesReceived, ZMQErrors)
129+
mfs, err := reg.Gather()
130+
if err != nil {
131+
t.Fatalf("Gather() failed: %v", err)
132+
}
133+
134+
for _, mf := range mfs {
135+
for _, m := range mf.GetMetric() {
136+
for _, l := range m.GetLabel() {
137+
if l.GetName() == podIdentifierLabel && l.GetValue() == "pod-a" {
138+
t.Errorf("metric %q still has a series for pod-a", mf.GetName())
139+
}
140+
}
141+
}
142+
}
143+
144+
// pod-b must be untouched: three series across the three metrics.
145+
remaining := 0
146+
for _, mf := range mfs {
147+
remaining += len(mf.GetMetric())
148+
}
149+
if remaining != 3 {
150+
t.Errorf("expected 3 remaining pod-b series, got %d", remaining)
151+
}
152+
153+
CleanupSubscriber("pod-b")
154+
}
155+
64156
func TestLogMetrics(t *testing.T) {
65157
// Set up a buffer to capture log output
66158
var buf bytes.Buffer

pkg/kvevents/pool.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"hash/fnv"
2121
"strings"
2222
"sync"
23+
"sync/atomic"
2324

2425
"k8s.io/client-go/util/workqueue"
2526
"sigs.k8s.io/controller-runtime/pkg/log"
@@ -114,11 +115,19 @@ type Pool struct {
114115
// Index.Add succeeds — both of which only the Pool observes.
115116
dedup *eventDedupFilter
116117
wg sync.WaitGroup
118+
// queueDepth mirrors the number of tasks queued across all shards. It is
119+
// tracked incrementally rather than by summing queue.Len() so that the
120+
// depth gauge stays O(1) on the enqueue/dequeue hot path.
121+
queueDepth atomic.Int64
117122
}
118123

119124
// NewPool creates a Pool with a sharded worker setup.
120125
// Subscribers are managed by SubscriberManager which is controlled by the pod
121126
// reconciler.
127+
//
128+
// Side effect: it registers the kvcache metrics with the controller-runtime
129+
// registry so that the kvevents metrics are scraped wherever a pool runs.
130+
// Registration is idempotent (guarded by a sync.Once).
122131
func NewPool(cfg *Config, index kvblock.Index, tokenProcessor kvblock.TokenProcessor,
123132
adapter EngineAdapter,
124133
) *Pool {
@@ -140,9 +149,17 @@ func NewPool(cfg *Config, index kvblock.Index, tokenProcessor kvblock.TokenProce
140149
p.queues[i] = workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[*RawMessage]())
141150
}
142151

152+
metrics.Register()
153+
143154
return p
144155
}
145156

157+
// addQueueDepth adjusts the tracked queue depth by delta and publishes the new
158+
// total to the depth gauge.
159+
func (p *Pool) addQueueDepth(delta int64) {
160+
metrics.PoolQueueDepth.Set(float64(p.queueDepth.Add(delta)))
161+
}
162+
146163
// GroupCatalog returns the KV cache group metadata learned from events.
147164
func (p *Pool) GroupCatalog() *kvblock.GroupCatalog {
148165
return p.groupCatalog
@@ -154,6 +171,8 @@ func (p *Pool) Start(ctx context.Context) {
154171
logger := log.FromContext(ctx)
155172
logger.Info("Starting sharded event processing pool", "workers", p.concurrency)
156173

174+
metrics.PoolCapacity.Set(float64(p.concurrency))
175+
157176
p.wg.Add(p.concurrency)
158177
for i := 0; i < p.concurrency; i++ {
159178
// Each worker is given its own dedicated queue shard.
@@ -171,6 +190,12 @@ func (p *Pool) Shutdown(ctx context.Context) {
171190
}
172191

173192
p.wg.Wait()
193+
194+
// Tasks still queued at shutdown are dropped with the queues, so reset the
195+
// depth rather than leaving the gauge pinned at the undrained count.
196+
p.queueDepth.Store(0)
197+
metrics.PoolQueueDepth.Set(0)
198+
174199
logger.Info("event processing pool shut down.")
175200
}
176201

@@ -189,6 +214,7 @@ func (p *Pool) AddTask(task *RawMessage) {
189214
//nolint:gosec // if concurrency overflows then the world is in trouble anyway
190215
queueIndex := h.Sum32() % uint32(p.concurrency)
191216
p.queues[queueIndex].Add(task)
217+
p.addQueueDepth(1)
192218
}
193219

194220
// worker is the main processing loop for a single worker goroutine.
@@ -209,6 +235,7 @@ func (p *Pool) worker(ctx context.Context, workerIndex int) {
209235
// Task succeeded, remove it from the queue.
210236
queue.Forget(task)
211237
}(task)
238+
p.addQueueDepth(-1)
212239

213240
// Check if context was cancelled after processing a task.
214241
select {

pkg/kvevents/pool_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kvevents //nolint:testpackage // tests use unexported processEventBatch
33
import (
44
"context"
55
"testing"
6+
"time"
67

78
"github.com/prometheus/client_golang/prometheus"
89
dto "github.com/prometheus/client_model/go"
@@ -1096,6 +1097,64 @@ func TestPool_DedupMetricsCountBlockHashes(t *testing.T) {
10961097
"second remove must forward all 4 constituent block hashes")
10971098
}
10981099

1100+
// stubAdapter is a minimal EngineAdapter that shards every message onto the
1101+
// same key and decodes to an empty batch, so tasks flow through the pool
1102+
// without exercising any engine-specific parsing.
1103+
type stubAdapter struct{}
1104+
1105+
//nolint:gocritic // unnamed results match the EngineAdapter implementations
1106+
func (stubAdapter) ParseMessage(_ *RawMessage) (string, string, EventBatch, error) {
1107+
return "pod-1", "model-1", EventBatch{}, nil
1108+
}
1109+
1110+
func (stubAdapter) ShardingKey(_ *RawMessage) string { return "pod-1" }
1111+
1112+
// TestPool_QueueDepthAccounting verifies that the queue depth gauge tracks
1113+
// enqueues and dequeues, and is reset once the pool shuts down.
1114+
func TestPool_QueueDepthAccounting(t *testing.T) {
1115+
idx, err := kvblock.NewInMemoryIndex(kvblock.DefaultInMemoryIndexConfig())
1116+
require.NoError(t, err)
1117+
1118+
tp, err := kvblock.NewChunkedTokenDatabase(&kvblock.TokenProcessorConfig{
1119+
BlockSizeTokens: 4, HashSeed: "test",
1120+
})
1121+
require.NoError(t, err)
1122+
1123+
cfg := DefaultConfig()
1124+
cfg.Concurrency = 2
1125+
pool := NewPool(cfg, idx, tp, stubAdapter{})
1126+
1127+
const tasks = 3
1128+
for i := range uint64(tasks) {
1129+
pool.AddTask(&RawMessage{Topic: "kv@pod-1@model-1", Sequence: i})
1130+
}
1131+
1132+
assert.Equal(t, int64(tasks), pool.queueDepth.Load(), "every enqueued task must be counted")
1133+
assert.InDelta(t, float64(tasks), gaugeValue(t, metrics.PoolQueueDepth), 0.001)
1134+
1135+
ctx, cancel := context.WithCancel(context.Background())
1136+
defer cancel()
1137+
pool.Start(ctx)
1138+
1139+
assert.Eventually(t, func() bool {
1140+
return pool.queueDepth.Load() == 0
1141+
}, 5*time.Second, 10*time.Millisecond, "workers must decrement the depth as tasks drain")
1142+
1143+
pool.Shutdown(ctx)
1144+
assert.Equal(t, int64(0), pool.queueDepth.Load())
1145+
assert.InDelta(t, 0.0, gaugeValue(t, metrics.PoolQueueDepth), 0.001)
1146+
assert.InDelta(t, float64(cfg.Concurrency), gaugeValue(t, metrics.PoolCapacity), 0.001)
1147+
}
1148+
1149+
// gaugeValue reads the current value of a prometheus.Gauge without touching the
1150+
// global registry.
1151+
func gaugeValue(t *testing.T, g prometheus.Gauge) float64 {
1152+
t.Helper()
1153+
var m dto.Metric
1154+
require.NoError(t, g.Write(&m))
1155+
return m.GetGauge().GetValue()
1156+
}
1157+
10991158
// counterValue reads the current value of a plain prometheus.Counter without
11001159
// touching the global registry, using the same dto.Metric.Write pattern as
11011160
// pkg/kvcache/metrics.logMetrics.

0 commit comments

Comments
 (0)